2026-03-16 15:27:35 +00:00
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
|
import maplibregl from 'maplibre-gl';
|
|
|
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
2026-03-16 23:36:06 +00:00
|
|
|
import * as turf from '@turf/turf';
|
2026-03-16 15:27:35 +00:00
|
|
|
import apiService from './services/api';
|
|
|
|
|
import './styles/App.css';
|
|
|
|
|
|
|
|
|
|
const APP = () => {
|
|
|
|
|
const mapContainerRef = useRef(null);
|
|
|
|
|
const mapRef = useRef(null);
|
2026-03-16 19:46:08 +00:00
|
|
|
const startMarkerRef = useRef(null);
|
2026-03-16 20:28:04 +00:00
|
|
|
const cityMarkersRef = useRef([]);
|
2026-03-16 23:35:22 +00:00
|
|
|
const animationRef = useRef(null);
|
2026-03-17 00:08:50 +00:00
|
|
|
const popupRef = useRef(null);
|
2026-04-08 16:15:58 +01:00
|
|
|
// Refs mirror state so stale closures (e.g. style.load) always read the current value
|
2026-04-01 12:06:32 +01:00
|
|
|
const mapProjectionRef = useRef('globe');
|
2026-04-08 16:15:58 +01:00
|
|
|
const selectedPointRef = useRef({ lat: 51.5074, lon: -0.1278 });
|
|
|
|
|
const directionRef = useRef(90);
|
|
|
|
|
const lineOfSightDataRef = useRef(null);
|
|
|
|
|
const flightProgressRef = useRef(0);
|
|
|
|
|
const seekRef = useRef(null);
|
|
|
|
|
const currentCityIndexRef = useRef(-1);
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-03-16 15:27:35 +00:00
|
|
|
const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 });
|
2026-04-08 16:15:58 +01:00
|
|
|
const [direction, setDirection] = useState(90);
|
2026-03-16 15:27:35 +00:00
|
|
|
const [lineOfSightData, setLineOfSightData] = useState(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
2026-03-16 23:35:22 +00:00
|
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
2026-03-17 00:08:50 +00:00
|
|
|
const [flightSpeed, setFlightSpeed] = useState(1.0);
|
2026-04-08 16:15:58 +01:00
|
|
|
const [mapStyle, setMapStyle] = useState('basic');
|
2026-04-01 12:06:32 +01:00
|
|
|
const [mapProjection, setMapProjection] = useState('globe');
|
2026-03-16 15:27:35 +00:00
|
|
|
const [tolerance, setTolerance] = useState(50);
|
2026-03-16 20:50:35 +00:00
|
|
|
const [selectedCity, setSelectedCity] = useState(null);
|
2026-03-16 23:19:40 +00:00
|
|
|
const [isLocked, setIsLocked] = useState(false);
|
2026-04-08 16:15:58 +01:00
|
|
|
const [currentCityIndex, setCurrentCityIndex] = useState(-1);
|
2026-03-16 15:27:35 +00:00
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
// Keep refs in sync with state so stale closures always have current values
|
|
|
|
|
useEffect(() => { mapProjectionRef.current = mapProjection; }, [mapProjection]);
|
|
|
|
|
useEffect(() => { selectedPointRef.current = selectedPoint; }, [selectedPoint]);
|
|
|
|
|
useEffect(() => { directionRef.current = direction; }, [direction]);
|
|
|
|
|
useEffect(() => { lineOfSightDataRef.current = lineOfSightData; }, [lineOfSightData]);
|
|
|
|
|
|
|
|
|
|
// Auto-scroll sidebar to current city during flight
|
2026-04-01 12:06:32 +01:00
|
|
|
useEffect(() => {
|
2026-04-08 16:15:58 +01:00
|
|
|
if (isPlaying && currentCityIndex >= 0) {
|
|
|
|
|
const row = document.getElementById(`city-row-${currentCityIndex}`);
|
|
|
|
|
if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
|
|
|
}
|
|
|
|
|
}, [currentCityIndex, isPlaying]);
|
2026-04-01 12:06:32 +01:00
|
|
|
|
|
|
|
|
// --- Helpers ---
|
|
|
|
|
|
|
|
|
|
// Build GeoJSON for the line, splitting at the antimeridian for flat projections
|
2026-04-08 16:15:58 +01:00
|
|
|
const buildLineGeoJSON = (lineCoords) => {
|
2026-04-01 12:06:32 +01:00
|
|
|
const coords = lineCoords.map(c => [c.lon, c.lat]);
|
|
|
|
|
// 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();
|
2026-04-08 16:15:58 +01:00
|
|
|
// MapLibre v5 requires ImageData, not a raw canvas element
|
|
|
|
|
map.addImage('arrow-icon', ctx.getImageData(0, 0, size, size));
|
2026-04-01 12:06:32 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// --- Map initialisation (runs once) ---
|
|
|
|
|
|
2026-03-16 15:27:35 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
mapRef.current = new maplibregl.Map({
|
|
|
|
|
container: mapContainerRef.current,
|
|
|
|
|
style: getMapStyle(mapStyle),
|
2026-04-01 12:06:32 +01:00
|
|
|
center: [-0.1278, 51.5074],
|
2026-03-16 23:19:40 +00:00
|
|
|
zoom: 2,
|
|
|
|
|
pitch: 0,
|
2026-04-08 16:15:58 +01:00
|
|
|
projection: { type: 'globe' }
|
2026-03-16 15:27:35 +00:00
|
|
|
});
|
|
|
|
|
|
2026-03-17 00:08:50 +00:00
|
|
|
mapRef.current.on('style.load', () => {
|
|
|
|
|
setupMapLayers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
|
|
|
|
if (mapRef.current) mapRef.current.remove();
|
|
|
|
|
};
|
2026-04-01 12:06:32 +01:00
|
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
2026-03-17 00:08:50 +00:00
|
|
|
|
|
|
|
|
const setupMapLayers = () => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
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({
|
|
|
|
|
'sky-color': '#199EF3',
|
|
|
|
|
'sky-horizon-blend': 0.5,
|
|
|
|
|
'horizon-color': '#ffffff',
|
|
|
|
|
'horizon-fog-blend': 0.5,
|
|
|
|
|
'fog-color': '#add8e6',
|
|
|
|
|
'fog-ground-blend': 0.5
|
2026-03-16 23:19:40 +00:00
|
|
|
});
|
2026-04-01 12:06:32 +01:00
|
|
|
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 });
|
2026-03-17 00:08:50 +00:00
|
|
|
}
|
2026-03-16 19:46:08 +00:00
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
// Read current values from refs to avoid stale closure problems
|
|
|
|
|
const currentPoint = selectedPointRef.current;
|
|
|
|
|
const currentDirection = directionRef.current;
|
|
|
|
|
const currentLineData = lineOfSightDataRef.current;
|
|
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
// 3. Start marker
|
2026-03-17 00:08:50 +00:00
|
|
|
if (startMarkerRef.current) startMarkerRef.current.remove();
|
|
|
|
|
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
|
2026-04-08 16:15:58 +01:00
|
|
|
.setLngLat([currentPoint.lon, currentPoint.lat])
|
2026-03-17 00:08:50 +00:00
|
|
|
.addTo(map);
|
|
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
// 4. Preview line source and layer
|
2026-03-17 00:08:50 +00:00
|
|
|
if (!map.getSource('preview-line')) {
|
|
|
|
|
map.addSource('preview-line', {
|
2026-03-16 19:46:08 +00:00
|
|
|
type: 'geojson',
|
2026-04-01 12:06:32 +01:00
|
|
|
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } }
|
2026-03-16 19:46:08 +00:00
|
|
|
});
|
2026-03-17 00:08:50 +00:00
|
|
|
map.addLayer({
|
2026-03-16 19:46:08 +00:00
|
|
|
id: 'preview-line',
|
|
|
|
|
type: 'line',
|
|
|
|
|
source: 'preview-line',
|
2026-04-01 12:06:32 +01:00
|
|
|
paint: { 'line-color': '#FF6B6B', 'line-width': 2, 'line-dasharray': [2, 2] }
|
2026-03-16 19:46:08 +00:00
|
|
|
});
|
2026-03-17 00:08:50 +00:00
|
|
|
}
|
2026-03-16 19:46:08 +00:00
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
// 5. Restore results line and markers if they existed
|
2026-04-08 16:15:58 +01:00
|
|
|
if (currentLineData) {
|
|
|
|
|
renderLineOnMap(currentLineData);
|
2026-03-17 00:08:50 +00:00
|
|
|
if (map.getLayer('preview-line')) {
|
|
|
|
|
map.setLayoutProperty('preview-line', 'visibility', 'none');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-04-08 16:15:58 +01:00
|
|
|
updatePreviewLine(currentPoint, currentDirection);
|
2026-03-17 00:08:50 +00:00
|
|
|
}
|
|
|
|
|
};
|
2026-03-16 23:19:40 +00:00
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
// --- Projection change effect ---
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
const applyProjection = () => {
|
|
|
|
|
map.setProjection({ type: 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);
|
|
|
|
|
}
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
// 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)
|
|
|
|
|
);
|
2026-04-01 12:06:32 +01:00
|
|
|
}
|
2026-04-08 16:15:58 +01:00
|
|
|
};
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
if (map.isStyleLoaded()) {
|
|
|
|
|
applyProjection();
|
|
|
|
|
} else {
|
|
|
|
|
map.once('style.load', applyProjection);
|
|
|
|
|
return () => map.off('style.load', applyProjection);
|
2026-04-01 12:06:32 +01:00
|
|
|
}
|
|
|
|
|
}, [mapProjection]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
// --- Click handler (respects isLocked) ---
|
|
|
|
|
|
2026-03-16 23:19:40 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!mapRef.current) return;
|
|
|
|
|
|
|
|
|
|
const handleClick = (e) => {
|
|
|
|
|
if (isLocked) return;
|
2026-03-16 15:27:35 +00:00
|
|
|
const { lng, lat } = e.lngLat;
|
2026-04-08 16:15:58 +01:00
|
|
|
// Update ref immediately (state update is async)
|
|
|
|
|
selectedPointRef.current = { lat, lon: lng };
|
2026-03-16 15:27:35 +00:00
|
|
|
setSelectedPoint({ lat, lon: lng });
|
2026-03-16 19:46:08 +00:00
|
|
|
if (startMarkerRef.current) {
|
|
|
|
|
startMarkerRef.current.setLngLat([lng, lat]);
|
|
|
|
|
}
|
2026-03-16 20:28:04 +00:00
|
|
|
clearCityMarkers();
|
2026-04-01 12:06:32 +01:00
|
|
|
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');
|
2026-03-16 15:27:35 +00:00
|
|
|
}
|
2026-04-08 16:15:58 +01:00
|
|
|
if (map.getLayer('preview-line')) {
|
|
|
|
|
map.setLayoutProperty('preview-line', 'visibility', 'visible');
|
|
|
|
|
}
|
2026-03-16 23:19:40 +00:00
|
|
|
};
|
2026-03-16 15:27:35 +00:00
|
|
|
|
2026-03-16 23:19:40 +00:00
|
|
|
mapRef.current.on('click', handleClick);
|
2026-03-16 15:27:35 +00:00
|
|
|
return () => {
|
2026-03-16 23:19:40 +00:00
|
|
|
if (mapRef.current) mapRef.current.off('click', handleClick);
|
2026-03-16 15:27:35 +00:00
|
|
|
};
|
2026-04-01 12:06:32 +01:00
|
|
|
}, [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 ---
|
2026-03-16 23:19:40 +00:00
|
|
|
|
|
|
|
|
const handleStartAgain = () => {
|
2026-03-16 23:35:22 +00:00
|
|
|
if (animationRef.current) {
|
|
|
|
|
cancelAnimationFrame(animationRef.current);
|
|
|
|
|
setIsPlaying(false);
|
2026-03-17 00:08:50 +00:00
|
|
|
setFlightSpeed(1.0);
|
2026-03-16 23:35:22 +00:00
|
|
|
}
|
2026-03-17 00:08:50 +00:00
|
|
|
if (popupRef.current) {
|
|
|
|
|
popupRef.current.remove();
|
|
|
|
|
popupRef.current = null;
|
|
|
|
|
}
|
2026-03-16 23:19:40 +00:00
|
|
|
setIsLocked(false);
|
|
|
|
|
setLineOfSightData(null);
|
|
|
|
|
setSelectedCity(null);
|
|
|
|
|
clearCityMarkers();
|
|
|
|
|
if (mapRef.current) {
|
2026-04-01 12:06:32 +01:00
|
|
|
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');
|
2026-03-16 23:19:40 +00:00
|
|
|
}
|
2026-04-01 12:06:32 +01:00
|
|
|
if (map.getLayer('preview-line')) {
|
|
|
|
|
map.setLayoutProperty('preview-line', 'visibility', 'visible');
|
2026-03-16 23:19:40 +00:00
|
|
|
}
|
2026-04-01 12:06:32 +01:00
|
|
|
map.flyTo({ center: [selectedPoint.lon, selectedPoint.lat], zoom: 3, pitch: 0, bearing: 0 });
|
2026-03-16 23:19:40 +00:00
|
|
|
}
|
|
|
|
|
};
|
2026-03-16 15:27:35 +00:00
|
|
|
|
2026-03-16 20:28:04 +00:00
|
|
|
const clearCityMarkers = () => {
|
|
|
|
|
if (cityMarkersRef.current) {
|
|
|
|
|
cityMarkersRef.current.forEach(marker => marker.remove());
|
|
|
|
|
cityMarkersRef.current = [];
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-17 00:08:50 +00:00
|
|
|
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>
|
|
|
|
|
`;
|
2026-04-01 12:06:32 +01:00
|
|
|
popupRef.current = new maplibregl.Popup({
|
|
|
|
|
closeButton: true,
|
2026-03-17 00:08:50 +00:00
|
|
|
closeOnClick: false,
|
|
|
|
|
maxWidth: '300px',
|
|
|
|
|
offset: [0, -20]
|
|
|
|
|
})
|
|
|
|
|
.setLngLat([city.lon, city.lat])
|
|
|
|
|
.setDOMContent(popupNode)
|
|
|
|
|
.addTo(mapRef.current);
|
2026-04-01 12:06:32 +01:00
|
|
|
popupRef.current.on('close', () => setSelectedCity(null));
|
2026-03-17 00:08:50 +00:00
|
|
|
};
|
|
|
|
|
|
2026-03-16 23:35:22 +00:00
|
|
|
const startFlyOver = () => {
|
|
|
|
|
if (!lineOfSightData || !mapRef.current) return;
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-03-16 23:35:22 +00:00
|
|
|
if (isPlaying) {
|
|
|
|
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
|
|
|
|
setIsPlaying(false);
|
2026-03-17 00:08:50 +00:00
|
|
|
setFlightSpeed(1.0);
|
2026-04-08 16:15:58 +01:00
|
|
|
setCurrentCityIndex(-1);
|
|
|
|
|
currentCityIndexRef.current = -1;
|
2026-03-16 23:35:22 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsPlaying(true);
|
2026-04-08 16:15:58 +01:00
|
|
|
setCurrentCityIndex(-1);
|
|
|
|
|
currentCityIndexRef.current = -1;
|
2026-03-17 00:08:50 +00:00
|
|
|
if (popupRef.current) popupRef.current.remove();
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-03-16 23:35:22 +00:00
|
|
|
const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]);
|
|
|
|
|
const route = turf.lineString(coordinates);
|
|
|
|
|
const routeDistance = turf.length(route);
|
|
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
let lastFetchedDist = flightProgressRef.current;
|
|
|
|
|
let currentProgress = flightProgressRef.current;
|
|
|
|
|
flightProgressRef.current = 0;
|
2026-03-17 00:08:50 +00:00
|
|
|
let lastTimestamp = performance.now();
|
|
|
|
|
let currentSpeedMultiplier = 1.0;
|
|
|
|
|
let frameCount = 0;
|
|
|
|
|
|
|
|
|
|
async function animate(currentTime) {
|
2026-03-16 23:35:22 +00:00
|
|
|
if (!mapRef.current) return;
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
// Apply any pending seek
|
|
|
|
|
if (seekRef.current !== null) {
|
|
|
|
|
currentProgress = seekRef.current;
|
|
|
|
|
lastFetchedDist = currentProgress;
|
|
|
|
|
lastTimestamp = currentTime;
|
|
|
|
|
seekRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 00:08:50 +00:00
|
|
|
const deltaTime = currentTime - lastTimestamp;
|
|
|
|
|
lastTimestamp = currentTime;
|
|
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
const baseKmPerMs = 0.1;
|
|
|
|
|
const pathPoints = lineOfSightDataRef.current.line_coordinates;
|
2026-03-17 00:08:50 +00:00
|
|
|
const pointIndex = Math.min(
|
|
|
|
|
Math.floor((currentProgress / routeDistance) * pathPoints.length),
|
|
|
|
|
pathPoints.length - 1
|
|
|
|
|
);
|
|
|
|
|
const isOverWater = pathPoints[pointIndex]?.is_over_water;
|
|
|
|
|
|
|
|
|
|
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++;
|
2026-04-01 12:06:32 +01:00
|
|
|
if (frameCount % 10 === 0) setFlightSpeed(currentSpeedMultiplier);
|
2026-03-17 00:08:50 +00:00
|
|
|
|
|
|
|
|
currentProgress += baseKmPerMs * deltaTime * currentSpeedMultiplier;
|
|
|
|
|
const phase = Math.min(currentProgress / routeDistance, 1);
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-03-16 23:35:22 +00:00
|
|
|
if (phase >= 1) {
|
|
|
|
|
setIsPlaying(false);
|
2026-03-17 00:08:50 +00:00
|
|
|
setFlightSpeed(1.0);
|
2026-04-08 16:15:58 +01:00
|
|
|
setCurrentCityIndex(-1);
|
|
|
|
|
currentCityIndexRef.current = -1;
|
2026-03-16 23:35:22 +00:00
|
|
|
return;
|
|
|
|
|
}
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-03-17 00:08:50 +00:00
|
|
|
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));
|
2026-03-16 23:35:22 +00:00
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
mapRef.current.jumpTo({ center: eye, zoom: 6, pitch: 65, bearing });
|
2026-03-17 00:08:50 +00:00
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
// Find nearest city to current position and update sidebar highlight
|
|
|
|
|
const cities = lineOfSightDataRef.current.conurbations;
|
|
|
|
|
let nearestIdx = -1;
|
|
|
|
|
let nearestDist = Infinity;
|
|
|
|
|
cities.forEach((city, idx) => {
|
|
|
|
|
const d = Math.abs(city.distance_km - currentProgress);
|
|
|
|
|
if (d < nearestDist) { nearestDist = d; nearestIdx = idx; }
|
|
|
|
|
});
|
|
|
|
|
if (nearestDist < 300 && nearestIdx !== currentCityIndexRef.current) {
|
|
|
|
|
currentCityIndexRef.current = nearestIdx;
|
|
|
|
|
setCurrentCityIndex(nearestIdx);
|
|
|
|
|
showCityPopup(cities[nearestIdx]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 00:08:50 +00:00
|
|
|
if (currentProgress - lastFetchedDist > 2000) {
|
|
|
|
|
lastFetchedDist = currentProgress;
|
|
|
|
|
try {
|
2026-04-01 12:06:32 +01:00
|
|
|
const response = await apiService.getLineOfSight(eye[1], eye[0], bearing, tolerance);
|
2026-03-17 00:08:50 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 23:35:22 +00:00
|
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
|
|
|
}
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-03-16 23:35:22 +00:00
|
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-17 00:08:50 +00:00
|
|
|
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>
|
|
|
|
|
`;
|
2026-04-01 12:06:32 +01:00
|
|
|
const marker = new maplibregl.Marker(el).setLngLat([city.lon, city.lat]).addTo(map);
|
2026-03-17 00:08:50 +00:00
|
|
|
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();
|
2026-04-01 12:06:32 +01:00
|
|
|
|
|
|
|
|
// Remove existing layers/source (arrow layer must go before line layer)
|
|
|
|
|
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
2026-03-17 00:08:50 +00:00
|
|
|
if (map.getSource('line-of-sight')) {
|
|
|
|
|
map.removeLayer('line-of-sight');
|
|
|
|
|
map.removeSource('line-of-sight');
|
2026-03-16 19:46:08 +00:00
|
|
|
}
|
2026-03-17 00:08:50 +00:00
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
const projection = mapProjectionRef.current;
|
|
|
|
|
|
2026-03-17 00:08:50 +00:00
|
|
|
map.addSource('line-of-sight', {
|
|
|
|
|
type: 'geojson',
|
2026-04-08 16:15:58 +01:00
|
|
|
data: buildLineGeoJSON(data.line_coordinates)
|
2026-03-17 00:08:50 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
id: 'line-of-sight',
|
|
|
|
|
type: 'line',
|
|
|
|
|
source: 'line-of-sight',
|
2026-04-01 12:06:32 +01:00
|
|
|
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',
|
2026-03-17 00:08:50 +00:00
|
|
|
layout: {
|
2026-04-01 12:06:32 +01:00
|
|
|
'symbol-placement': 'line',
|
|
|
|
|
'symbol-spacing': 300,
|
|
|
|
|
'icon-image': 'arrow-icon',
|
|
|
|
|
'icon-size': 1.0,
|
|
|
|
|
'icon-allow-overlap': true,
|
|
|
|
|
'icon-ignore-placement': true
|
2026-03-17 00:08:50 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
addMarkersToMap(data.conurbations);
|
|
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
// 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
|
2026-03-17 00:08:50 +00:00
|
|
|
});
|
|
|
|
|
};
|
2026-03-16 19:46:08 +00:00
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
const updatePreviewLine = (point, brng) => {
|
2026-03-16 19:46:08 +00:00
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map || !map.getSource('preview-line')) return;
|
|
|
|
|
|
|
|
|
|
const path = [];
|
2026-04-01 12:06:32 +01:00
|
|
|
const steps = 160;
|
|
|
|
|
const totalDistance = 40074; // Full Earth circumference (km)
|
2026-03-16 19:46:08 +00:00
|
|
|
|
|
|
|
|
for (let i = 0; i <= steps; i++) {
|
|
|
|
|
const dist = (totalDistance * i) / steps;
|
2026-04-01 12:06:32 +01:00
|
|
|
path.push(calculateDestination(point.lat, point.lon, brng, dist));
|
2026-03-16 19:46:08 +00:00
|
|
|
}
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-04-08 16:15:58 +01:00
|
|
|
map.getSource('preview-line').setData(
|
|
|
|
|
buildLineGeoJSON(path)
|
|
|
|
|
);
|
2026-03-16 19:46:08 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const calculateDestination = (lat, lon, bearing, distance) => {
|
2026-04-01 12:06:32 +01:00
|
|
|
const R = 6371;
|
2026-03-16 19:46:08 +00:00
|
|
|
const brng = (bearing * Math.PI) / 180;
|
|
|
|
|
const φ1 = (lat * Math.PI) / 180;
|
|
|
|
|
const λ1 = (lon * Math.PI) / 180;
|
|
|
|
|
const δ = distance / R;
|
|
|
|
|
|
|
|
|
|
const φ2 = Math.asin(
|
|
|
|
|
Math.sin(φ1) * Math.cos(δ) +
|
|
|
|
|
Math.cos(φ1) * Math.sin(δ) * Math.cos(brng)
|
|
|
|
|
);
|
|
|
|
|
const λ2 =
|
|
|
|
|
λ1 +
|
|
|
|
|
Math.atan2(
|
|
|
|
|
Math.sin(brng) * Math.sin(δ) * Math.cos(φ1),
|
|
|
|
|
Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
lat: (φ2 * 180) / Math.PI,
|
|
|
|
|
lon: (((λ2 * 180) / Math.PI + 540) % 360) - 180
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-16 15:27:35 +00:00
|
|
|
const getMapStyle = (style) => {
|
2026-03-17 00:08:50 +00:00
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-01 12:06:32 +01:00
|
|
|
layers: [{
|
|
|
|
|
id: 'satellite-layer',
|
|
|
|
|
type: 'raster',
|
|
|
|
|
source: 'esri-satellite',
|
|
|
|
|
minzoom: 0,
|
|
|
|
|
maxzoom: 20
|
|
|
|
|
}]
|
2026-03-17 00:08:50 +00:00
|
|
|
};
|
|
|
|
|
}
|
2026-04-08 16:15:58 +01:00
|
|
|
if (style === 'map') {
|
|
|
|
|
return 'https://tiles.openfreemap.org/styles/liberty';
|
|
|
|
|
}
|
|
|
|
|
// 'basic' — clean MapLibre demo tiles, no labels
|
2026-04-01 12:06:32 +01:00
|
|
|
return 'https://demotiles.maplibre.org/style.json';
|
2026-03-16 15:27:35 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleShowLineOfSight = async () => {
|
|
|
|
|
setLoading(true);
|
2026-03-16 20:50:35 +00:00
|
|
|
setSelectedCity(null);
|
2026-03-16 15:27:35 +00:00
|
|
|
try {
|
|
|
|
|
const response = await apiService.getLineOfSight(
|
2026-04-01 12:06:32 +01:00
|
|
|
selectedPoint.lat,
|
|
|
|
|
selectedPoint.lon,
|
|
|
|
|
direction,
|
2026-03-16 15:27:35 +00:00
|
|
|
tolerance
|
|
|
|
|
);
|
2026-03-16 20:28:04 +00:00
|
|
|
setLineOfSightData(response.data.data);
|
2026-04-01 12:06:32 +01:00
|
|
|
setIsLocked(true);
|
2026-03-16 23:19:40 +00:00
|
|
|
if (mapRef.current && mapRef.current.getLayer('preview-line')) {
|
|
|
|
|
mapRef.current.setLayoutProperty('preview-line', 'visibility', 'none');
|
|
|
|
|
}
|
2026-03-16 20:28:04 +00:00
|
|
|
renderLineOnMap(response.data.data);
|
2026-03-16 15:27:35 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error fetching line of sight:', error);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-01 12:06:32 +01:00
|
|
|
const PROJECTIONS = [
|
|
|
|
|
{ id: 'globe', label: 'Globe' },
|
|
|
|
|
{ id: 'mercator', label: 'Mercator' },
|
2026-04-08 16:15:58 +01:00
|
|
|
{ id: 'vertical-perspective', label: 'Perspective' }
|
2026-04-01 12:06:32 +01:00
|
|
|
];
|
|
|
|
|
|
2026-03-16 15:27:35 +00:00
|
|
|
return (
|
|
|
|
|
<div className="app-container">
|
2026-04-01 12:06:32 +01:00
|
|
|
<div className="map-container" ref={mapContainerRef} />
|
|
|
|
|
|
2026-03-16 15:27:35 +00:00
|
|
|
<div className="controls">
|
2026-03-16 23:19:40 +00:00
|
|
|
<div className={`control-group ${isLocked ? 'disabled-controls' : ''}`}>
|
2026-03-16 15:27:35 +00:00
|
|
|
<h3>Line of Sight Settings</h3>
|
2026-04-01 12:06:32 +01:00
|
|
|
|
2026-03-16 15:27:35 +00:00
|
|
|
<div className="setting-row">
|
|
|
|
|
<label>Start Point:</label>
|
|
|
|
|
<span>{selectedPoint.lat.toFixed(4)}, {selectedPoint.lon.toFixed(4)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="setting-row">
|
|
|
|
|
<label>Direction (0-360°):</label>
|
2026-04-01 12:06:32 +01:00
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min="0"
|
|
|
|
|
max="360"
|
|
|
|
|
value={direction}
|
2026-03-16 23:19:40 +00:00
|
|
|
disabled={isLocked}
|
2026-03-16 15:27:35 +00:00
|
|
|
onChange={(e) => setDirection(parseInt(e.target.value))}
|
|
|
|
|
/>
|
|
|
|
|
<span>{direction}°</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="setting-row">
|
|
|
|
|
<label>Fuzziness Tolerance (km):</label>
|
2026-04-01 12:06:32 +01:00
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
value={tolerance}
|
2026-03-16 23:19:40 +00:00
|
|
|
disabled={isLocked}
|
2026-03-16 15:27:35 +00:00
|
|
|
onChange={(e) => setTolerance(parseInt(e.target.value))}
|
|
|
|
|
min="10"
|
|
|
|
|
max="200"
|
|
|
|
|
/>
|
|
|
|
|
<span>{tolerance} km</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="setting-row">
|
|
|
|
|
<label>Map Style:</label>
|
2026-04-08 16:15:58 +01:00
|
|
|
<button className={mapStyle === 'basic' ? 'active-style' : ''} onClick={() => setMapStyle('basic')}>Basic</button>
|
|
|
|
|
<button className={mapStyle === 'map' ? 'active-style' : ''} onClick={() => setMapStyle('map')}>Map</button>
|
2026-04-01 12:06:32 +01:00
|
|
|
<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
|
|
|
|
|
key={p.id}
|
|
|
|
|
className={mapProjection === p.id ? 'active-style' : ''}
|
|
|
|
|
onClick={() => setMapProjection(p.id)}
|
|
|
|
|
>
|
|
|
|
|
{p.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-03-16 15:27:35 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-03-16 23:19:40 +00:00
|
|
|
{!isLocked ? (
|
2026-04-01 12:06:32 +01:00
|
|
|
<button className="action-btn" onClick={handleShowLineOfSight} disabled={loading}>
|
2026-03-16 23:19:40 +00:00
|
|
|
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-04-01 12:06:32 +01:00
|
|
|
<button
|
|
|
|
|
className="action-btn"
|
2026-03-16 23:19:40 +00:00
|
|
|
onClick={startFlyOver}
|
|
|
|
|
style={{ backgroundColor: isPlaying ? '#e74c3c' : '#3498db' }}
|
|
|
|
|
>
|
2026-03-17 00:08:50 +00:00
|
|
|
{isPlaying ? `⏹ Stop Flight (${flightSpeed.toFixed(1)}x)` : '✈️ Fly Over Route'}
|
2026-03-16 23:19:40 +00:00
|
|
|
</button>
|
2026-04-01 12:06:32 +01:00
|
|
|
<button className="action-btn-secondary" onClick={handleStartAgain}>
|
2026-03-16 23:19:40 +00:00
|
|
|
🔄 Start Again
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-03-16 15:27:35 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{lineOfSightData && (
|
|
|
|
|
<div className="results-panel">
|
2026-04-08 16:15:58 +01:00
|
|
|
<h3>Cities Along Route ({lineOfSightData.conurbations.length})</h3>
|
|
|
|
|
{isPlaying && currentCityIndex >= 0 && (
|
|
|
|
|
<div className="now-passing">
|
|
|
|
|
Now passing: <strong>{lineOfSightData.conurbations[currentCityIndex]?.name}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<p className="hint">{isPlaying ? 'Click a city to jump to it' : 'Click a city for details'}</p>
|
2026-03-16 15:27:35 +00:00
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>#</th>
|
|
|
|
|
<th>City</th>
|
2026-04-08 16:15:58 +01:00
|
|
|
<th>Pop.</th>
|
2026-03-16 20:50:35 +00:00
|
|
|
<th>Dist.</th>
|
2026-03-16 15:27:35 +00:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
2026-04-08 16:15:58 +01:00
|
|
|
{lineOfSightData.conurbations.map((city, index) => {
|
|
|
|
|
let rowClass = '';
|
|
|
|
|
if (isPlaying && currentCityIndex >= 0) {
|
|
|
|
|
if (index === currentCityIndex) rowClass = 'current-row';
|
|
|
|
|
else if (index < currentCityIndex) rowClass = 'passed-row';
|
|
|
|
|
else if (index <= currentCityIndex + 5) rowClass = 'upcoming-row';
|
|
|
|
|
} else if (selectedCity?.id === city.id) {
|
|
|
|
|
rowClass = 'selected-row';
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<tr
|
|
|
|
|
id={`city-row-${index}`}
|
|
|
|
|
key={city.id}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedCity(city);
|
|
|
|
|
showCityPopup(city);
|
|
|
|
|
mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 });
|
|
|
|
|
if (isPlaying) {
|
|
|
|
|
seekRef.current = city.distance_km;
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className={rowClass}
|
|
|
|
|
>
|
|
|
|
|
<td>{index + 1}</td>
|
|
|
|
|
<td>{city.name}</td>
|
|
|
|
|
<td>{(city.population / 1000).toFixed(0)}k</td>
|
|
|
|
|
<td>{city.distance_km}km</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-03-16 15:27:35 +00:00
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="instructions">
|
|
|
|
|
<h3>How to Use</h3>
|
|
|
|
|
<ol>
|
|
|
|
|
<li>Click anywhere on the map to select a starting point</li>
|
|
|
|
|
<li>Adjust the direction using the slider (0-360°)</li>
|
|
|
|
|
<li>Set your fuzziness tolerance (how close cities must be to the line)</li>
|
|
|
|
|
<li>Click "Show Line of Sight" to visualize the path and cities</li>
|
|
|
|
|
</ol>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default APP;
|