Add better handling of places along line.
This commit is contained in:
+22
-7
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user