sc/full-world-wrap #6
+22
-7
@@ -99,31 +99,46 @@ app.get('/api/line-of-sight', async (req, res) => {
|
|||||||
ST_X(geom::geometry) as lon,
|
ST_X(geom::geometry) as lon,
|
||||||
ST_Distance(geom, (SELECT route FROM path)) / 1000 as distance_off_line_km,
|
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_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,
|
ST_LineLocatePoint((SELECT route FROM path)::geography::geometry, geom::geometry) as pos_on_line,
|
||||||
FLOOR(ST_Distance(geom, (SELECT start_node FROM path)) / 1000 / 100)::int as bin_100km
|
FLOOR(ST_LineLocatePoint((SELECT route FROM path)::geography::geometry, geom::geometry) * 200)::int as bin_200km
|
||||||
FROM cities
|
FROM cities
|
||||||
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
|
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
|
||||||
),
|
),
|
||||||
ranked AS (
|
ranked AS (
|
||||||
SELECT *,
|
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
|
FROM candidates
|
||||||
)
|
)
|
||||||
SELECT id, name, population, country, lat, lon,
|
SELECT id, name, population, country, lat, lon,
|
||||||
distance_off_line_km, distance_from_start_km, pos_on_line
|
distance_off_line_km, distance_from_start_km, pos_on_line
|
||||||
FROM ranked
|
FROM ranked
|
||||||
WHERE rank_in_bin <= 5
|
WHERE rank_in_bin <= 10
|
||||||
ORDER BY pos_on_line ASC;
|
ORDER BY pos_on_line ASC;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
|
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
|
||||||
|
|
||||||
// Greedy 30 km deduplication: sort by population desc, accept a city only if
|
// Greedy dynamic deduplication: sort by population desc, accept a city only if
|
||||||
// it's at least 30 km from every already-accepted city.
|
// 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 byPopulation = [...result.rows].sort((a, b) => (b.population || 0) - (a.population || 0));
|
||||||
const accepted = [];
|
const accepted = [];
|
||||||
for (const city of byPopulation) {
|
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);
|
if (!tooClose) accepted.push(city);
|
||||||
}
|
}
|
||||||
accepted.sort((a, b) => a.pos_on_line - b.pos_on_line);
|
accepted.sort((a, b) => a.pos_on_line - b.pos_on_line);
|
||||||
|
|||||||
+117
-58
@@ -5,11 +5,34 @@ import * as turf from '@turf/turf';
|
|||||||
import apiService from './services/api';
|
import apiService from './services/api';
|
||||||
import './styles/App.css';
|
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 APP = () => {
|
||||||
const mapContainerRef = useRef(null);
|
const mapContainerRef = useRef(null);
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const startMarkerRef = useRef(null);
|
const startMarkerRef = useRef(null);
|
||||||
const cityMarkersRef = useRef([]);
|
|
||||||
const animationRef = useRef(null);
|
const animationRef = useRef(null);
|
||||||
const popupRef = useRef(null);
|
const popupRef = useRef(null);
|
||||||
// Refs mirror state so stale closures (e.g. style.load) always read the current value
|
// Refs mirror state so stale closures (e.g. style.load) always read the current value
|
||||||
@@ -44,7 +67,7 @@ const APP = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPlaying && currentCityIndex >= 0) {
|
if (isPlaying && currentCityIndex >= 0) {
|
||||||
const row = document.getElementById(`city-row-${currentCityIndex}`);
|
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]);
|
}, [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) {
|
if (currentLineData) {
|
||||||
renderLineOnMap(currentLineData);
|
renderLineOnMap(currentLineData);
|
||||||
if (map.getLayer('preview-line')) {
|
if (map.getLayer('preview-line')) {
|
||||||
@@ -237,7 +319,7 @@ const APP = () => {
|
|||||||
if (startMarkerRef.current) {
|
if (startMarkerRef.current) {
|
||||||
startMarkerRef.current.setLngLat([lng, lat]);
|
startMarkerRef.current.setLngLat([lng, lat]);
|
||||||
}
|
}
|
||||||
clearCityMarkers();
|
syncCitiesToMap([]);
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
||||||
if (map.getSource('line-of-sight')) {
|
if (map.getSource('line-of-sight')) {
|
||||||
@@ -285,7 +367,7 @@ const APP = () => {
|
|||||||
setIsLocked(false);
|
setIsLocked(false);
|
||||||
setLineOfSightData(null);
|
setLineOfSightData(null);
|
||||||
setSelectedCity(null);
|
setSelectedCity(null);
|
||||||
clearCityMarkers();
|
syncCitiesToMap([]);
|
||||||
if (mapRef.current) {
|
if (mapRef.current) {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
||||||
@@ -300,11 +382,23 @@ const APP = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearCityMarkers = () => {
|
const syncCitiesToMap = (cities) => {
|
||||||
if (cityMarkersRef.current) {
|
const map = mapRef.current;
|
||||||
cityMarkersRef.current.forEach(marker => marker.remove());
|
if (!map || !map.getSource('cities')) return;
|
||||||
cityMarkersRef.current = [];
|
|
||||||
|
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) => {
|
const getCountryName = (code) => {
|
||||||
@@ -468,7 +562,7 @@ const APP = () => {
|
|||||||
...prev,
|
...prev,
|
||||||
conurbations: [...prev.conurbations, ...adjustedCities].sort((a, b) => a.distance_km - b.distance_km)
|
conurbations: [...prev.conurbations, ...adjustedCities].sort((a, b) => a.distance_km - b.distance_km)
|
||||||
};
|
};
|
||||||
addMarkersToMap(adjustedCities, prev.conurbations.length);
|
syncCitiesToMap(updatedData.conurbations);
|
||||||
return updatedData;
|
return updatedData;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -483,35 +577,10 @@ const APP = () => {
|
|||||||
animationRef.current = requestAnimationFrame(animate);
|
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 renderLineOnMap = (data) => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
clearCityMarkers();
|
|
||||||
|
|
||||||
// Remove existing layers/source (arrow layer must go before line layer)
|
// 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.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
||||||
if (map.getSource('line-of-sight')) {
|
if (map.getSource('line-of-sight')) {
|
||||||
@@ -521,6 +590,8 @@ const APP = () => {
|
|||||||
|
|
||||||
const projection = mapProjectionRef.current;
|
const projection = mapProjectionRef.current;
|
||||||
|
|
||||||
|
syncCitiesToMap([]);
|
||||||
|
|
||||||
map.addSource('line-of-sight', {
|
map.addSource('line-of-sight', {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
data: buildLineGeoJSON(data.line_coordinates)
|
data: buildLineGeoJSON(data.line_coordinates)
|
||||||
@@ -548,8 +619,8 @@ const APP = () => {
|
|||||||
'icon-ignore-placement': true
|
'icon-ignore-placement': true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
addArrowImage(map);
|
||||||
addMarkersToMap(data.conurbations);
|
syncCitiesToMap(data.conurbations);
|
||||||
|
|
||||||
// Zoom to show the full path from the start point
|
// Zoom to show the full path from the start point
|
||||||
map.flyTo({
|
map.flyTo({
|
||||||
@@ -791,19 +862,14 @@ const APP = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{lineOfSightData.conurbations.map((city, index) => {
|
{lineOfSightData.conurbations.map((city, index) => (
|
||||||
let rowClass = '';
|
<CityRow
|
||||||
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}
|
key={city.id}
|
||||||
|
city={city}
|
||||||
|
index={index}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
currentCityIndex={currentCityIndex}
|
||||||
|
isSelected={selectedCity?.id === city.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCity(city);
|
setSelectedCity(city);
|
||||||
showCityPopup(city);
|
showCityPopup(city);
|
||||||
@@ -812,15 +878,8 @@ const APP = () => {
|
|||||||
seekRef.current = city.distance_km;
|
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel th {
|
.results-panel th {
|
||||||
@@ -213,6 +214,13 @@
|
|||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #ddd;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel tr {
|
||||||
|
will-change: transform, background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel tr:nth-child(even) {
|
.results-panel tr:nth-child(even) {
|
||||||
@@ -346,63 +354,6 @@
|
|||||||
margin-bottom: 8px;
|
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 {
|
.map-info-popup {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
|||||||
Reference in New Issue
Block a user