Add better handling of places along line.
Tests / backend-test (pull_request) Successful in 9s
Tests / frontend-test (pull_request) Failing after 8s
Tests / e2e-test (pull_request) Failing after 1m30s

This commit is contained in:
(jenkins)
2026-04-16 23:22:42 +01:00
parent b98bac9cff
commit 62959398d5
3 changed files with 157 additions and 132 deletions
+127 -68
View File
@@ -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>
+8 -57
View File
@@ -200,6 +200,7 @@
width: 100%;
border-collapse: collapse;
font-size: 13px;
table-layout: fixed;
}
.results-panel th {
@@ -213,6 +214,13 @@
padding: 8px 4px;
border-bottom: 1px solid #ddd;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-panel tr {
will-change: transform, background-color;
}
.results-panel tr:nth-child(even) {
@@ -346,63 +354,6 @@
margin-bottom: 8px;
}
.city-marker {
display: flex;
flex-direction: column;
align-items: center;
font-family: sans-serif;
z-index: 100;
cursor: pointer;
pointer-events: auto !important;
}
.marker-number {
background: #2c3e50;
color: white;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
margin-bottom: -4px;
border: 1px solid white;
z-index: 2;
}
.marker-dot {
width: 12px;
height: 12px;
background: #e74c3c;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
z-index: 1;
}
.marker-label {
background: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
margin-top: 2px;
white-space: nowrap;
}
.marker-pop {
background: #34495e;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
margin-top: 2px;
white-space: nowrap;
}
.map-info-popup {
padding: 5px;
font-family: sans-serif;