From 62959398d5b87e4b760241e5bb1c94fc76d722e5 Mon Sep 17 00:00:00 2001
From: "(jenkins)" <(jenkins)>
Date: Thu, 16 Apr 2026 23:22:42 +0100
Subject: [PATCH] Add better handling of places along line.
---
backend/app/server.js | 29 ++++--
frontend/src/App.jsx | 195 +++++++++++++++++++++++-------------
frontend/src/styles/App.css | 65 ++----------
3 files changed, 157 insertions(+), 132 deletions(-)
diff --git a/backend/app/server.js b/backend/app/server.js
index 5500846..d11d764 100644
--- a/backend/app/server.js
+++ b/backend/app/server.js
@@ -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);
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 8585f4a..37b6d63 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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 (
+
+ | {index + 1} |
+ {city.name} |
+ {(city.population / 1000).toFixed(0)}k |
+ {city.distance_km}km |
+
+ );
+});
+
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 = `
- ${displayIndex}
-
- ${city.name}
- ${(city.population / 1000).toFixed(0)}k
- `;
- 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 = () => {
- {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 (
- {
- setSelectedCity(city);
- showCityPopup(city);
- mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 });
- if (isPlaying) {
- seekRef.current = city.distance_km;
- }
- }}
- className={rowClass}
- >
- | {index + 1} |
- {city.name} |
- {(city.population / 1000).toFixed(0)}k |
- {city.distance_km}km |
-
- );
- })}
+ {lineOfSightData.conurbations.map((city, index) => (
+ {
+ setSelectedCity(city);
+ showCityPopup(city);
+ mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 });
+ if (isPlaying) {
+ seekRef.current = city.distance_km;
+ }
+ }}
+ />
+ ))}
diff --git a/frontend/src/styles/App.css b/frontend/src/styles/App.css
index 8c1c91c..0f7ceeb 100644
--- a/frontend/src/styles/App.css
+++ b/frontend/src/styles/App.css
@@ -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;