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;