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
+22 -7
View File
@@ -99,31 +99,46 @@ app.get('/api/line-of-sight', async (req, res) => {
ST_X(geom::geometry) as lon,
ST_Distance(geom, (SELECT route FROM path)) / 1000 as distance_off_line_km,
ST_Distance(geom, (SELECT start_node FROM path)) / 1000 as distance_from_start_km,
ST_LineLocatePoint((SELECT route FROM path)::geometry, geom::geometry) as pos_on_line,
FLOOR(ST_Distance(geom, (SELECT start_node FROM path)) / 1000 / 100)::int as bin_100km
ST_LineLocatePoint((SELECT route FROM path)::geography::geometry, geom::geometry) as pos_on_line,
FLOOR(ST_LineLocatePoint((SELECT route FROM path)::geography::geometry, geom::geometry) * 200)::int as bin_200km
FROM cities
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
),
ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY bin_100km ORDER BY population DESC NULLS LAST) as rank_in_bin
ROW_NUMBER() OVER (PARTITION BY bin_200km ORDER BY population DESC NULLS LAST) as rank_in_bin
FROM candidates
)
SELECT id, name, population, country, lat, lon,
distance_off_line_km, distance_from_start_km, pos_on_line
FROM ranked
WHERE rank_in_bin <= 5
WHERE rank_in_bin <= 10
ORDER BY pos_on_line ASC;
`;
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
// Greedy 30 km deduplication: sort by population desc, accept a city only if
// it's at least 30 km from every already-accepted city.
// Greedy dynamic deduplication: sort by population desc, accept a city only if
// it satisfies a distance threshold that depends on its importance (population).
const byPopulation = [...result.rows].sort((a, b) => (b.population || 0) - (a.population || 0));
const accepted = [];
for (const city of byPopulation) {
const tooClose = accepted.some(s => haversineKm(s.lat, s.lon, city.lat, city.lon) < 30);
const cityPop = city.population || 0;
const tooClose = accepted.some(s => {
const dist = haversineKm(s.lat, s.lon, city.lat, city.lon);
const sPop = s.population || 0;
// Major hubs (>1M) can be closer (30km) to each other (e.g., Tokyo/Yokohama)
if (cityPop > 1000000 && sPop > 1000000) return dist < 30;
// Large cities (>100k) need at least 50km space
if (cityPop > 100000 || sPop > 100000) return dist < 50;
// Smaller towns and villages need 80km space from any other accepted place
// to prevent clutter in high-density regions (like Europe/East Coast).
// Isolated towns (Alaska/Outback) will still show up as there's nothing else near them.
return dist < 80;
});
if (!tooClose) accepted.push(city);
}
accepted.sort((a, b) => a.pos_on_line - b.pos_on_line);
+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;