Add better handling of places along line.
This commit is contained in:
+127
-68
@@ -5,11 +5,34 @@ import * as turf from '@turf/turf';
|
||||
import apiService from './services/api';
|
||||
import './styles/App.css';
|
||||
|
||||
const CityRow = React.memo(({ city, index, isPlaying, currentCityIndex, isSelected, onClick }) => {
|
||||
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 (isSelected) {
|
||||
rowClass = 'selected-row';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
id={`city-row-${index}`}
|
||||
onClick={onClick}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
const APP = () => {
|
||||
const mapContainerRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const startMarkerRef = useRef(null);
|
||||
const cityMarkersRef = useRef([]);
|
||||
const animationRef = useRef(null);
|
||||
const popupRef = useRef(null);
|
||||
// Refs mirror state so stale closures (e.g. style.load) always read the current value
|
||||
@@ -44,7 +67,7 @@ const APP = () => {
|
||||
useEffect(() => {
|
||||
if (isPlaying && currentCityIndex >= 0) {
|
||||
const row = document.getElementById(`city-row-${currentCityIndex}`);
|
||||
if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
if (row) row.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||
}
|
||||
}, [currentCityIndex, isPlaying]);
|
||||
|
||||
@@ -171,7 +194,66 @@ const APP = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Restore results line and markers if they existed
|
||||
// 5. Cities source and layers (Symbol/Circle for high performance & terrain alignment)
|
||||
if (!map.getSource('cities')) {
|
||||
map.addSource('cities', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] }
|
||||
});
|
||||
|
||||
// City dot (Circle)
|
||||
map.addLayer({
|
||||
id: 'cities-circle',
|
||||
type: 'circle',
|
||||
source: 'cities',
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#e74c3c',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
});
|
||||
|
||||
// City label (Symbol)
|
||||
map.addLayer({
|
||||
id: 'cities-label',
|
||||
type: 'symbol',
|
||||
source: 'cities',
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-size': 12,
|
||||
'text-offset': [0, 1.5],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#2c3e50',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
});
|
||||
|
||||
// Handle city clicks
|
||||
map.on('click', 'cities-circle', (e) => {
|
||||
const feature = e.features[0];
|
||||
if (feature) {
|
||||
const city = JSON.parse(feature.properties.cityData);
|
||||
setSelectedCity(city);
|
||||
showCityPopup(city);
|
||||
}
|
||||
});
|
||||
|
||||
map.on('mouseenter', 'cities-circle', () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
map.on('mouseleave', 'cities-circle', () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Restore result data if it exists
|
||||
if (currentLineData) {
|
||||
renderLineOnMap(currentLineData);
|
||||
if (map.getLayer('preview-line')) {
|
||||
@@ -237,7 +319,7 @@ const APP = () => {
|
||||
if (startMarkerRef.current) {
|
||||
startMarkerRef.current.setLngLat([lng, lat]);
|
||||
}
|
||||
clearCityMarkers();
|
||||
syncCitiesToMap([]);
|
||||
const map = mapRef.current;
|
||||
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
||||
if (map.getSource('line-of-sight')) {
|
||||
@@ -285,7 +367,7 @@ const APP = () => {
|
||||
setIsLocked(false);
|
||||
setLineOfSightData(null);
|
||||
setSelectedCity(null);
|
||||
clearCityMarkers();
|
||||
syncCitiesToMap([]);
|
||||
if (mapRef.current) {
|
||||
const map = mapRef.current;
|
||||
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
||||
@@ -300,11 +382,23 @@ const APP = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearCityMarkers = () => {
|
||||
if (cityMarkersRef.current) {
|
||||
cityMarkersRef.current.forEach(marker => marker.remove());
|
||||
cityMarkersRef.current = [];
|
||||
}
|
||||
const syncCitiesToMap = (cities) => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.getSource('cities')) return;
|
||||
|
||||
const geojson = {
|
||||
type: 'FeatureCollection',
|
||||
features: cities.map((city) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [city.lon, city.lat] },
|
||||
properties: {
|
||||
name: city.name,
|
||||
cityData: JSON.stringify(city)
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
map.getSource('cities').setData(geojson);
|
||||
};
|
||||
|
||||
const getCountryName = (code) => {
|
||||
@@ -468,7 +562,7 @@ const APP = () => {
|
||||
...prev,
|
||||
conurbations: [...prev.conurbations, ...adjustedCities].sort((a, b) => a.distance_km - b.distance_km)
|
||||
};
|
||||
addMarkersToMap(adjustedCities, prev.conurbations.length);
|
||||
syncCitiesToMap(updatedData.conurbations);
|
||||
return updatedData;
|
||||
});
|
||||
}
|
||||
@@ -483,35 +577,10 @@ const APP = () => {
|
||||
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 = `
|
||||
<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>
|
||||
`;
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
const renderLineOnMap = (data) => {
|
||||
const map = mapRef.current;
|
||||
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')) {
|
||||
@@ -521,6 +590,8 @@ const APP = () => {
|
||||
|
||||
const projection = mapProjectionRef.current;
|
||||
|
||||
syncCitiesToMap([]);
|
||||
|
||||
map.addSource('line-of-sight', {
|
||||
type: 'geojson',
|
||||
data: buildLineGeoJSON(data.line_coordinates)
|
||||
@@ -548,8 +619,8 @@ const APP = () => {
|
||||
'icon-ignore-placement': true
|
||||
}
|
||||
});
|
||||
|
||||
addMarkersToMap(data.conurbations);
|
||||
addArrowImage(map);
|
||||
syncCitiesToMap(data.conurbations);
|
||||
|
||||
// Zoom to show the full path from the start point
|
||||
map.flyTo({
|
||||
@@ -791,36 +862,24 @@ const APP = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{lineOfSightData.conurbations.map((city, index) => (
|
||||
<CityRow
|
||||
key={city.id}
|
||||
city={city}
|
||||
index={index}
|
||||
isPlaying={isPlaying}
|
||||
currentCityIndex={currentCityIndex}
|
||||
isSelected={selectedCity?.id === city.id}
|
||||
onClick={() => {
|
||||
setSelectedCity(city);
|
||||
showCityPopup(city);
|
||||
mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 });
|
||||
if (isPlaying) {
|
||||
seekRef.current = city.distance_km;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user