diff --git a/backend/app/server.js b/backend/app/server.js index 507ce0c..3cd62b7 100644 --- a/backend/app/server.js +++ b/backend/app/server.js @@ -53,8 +53,8 @@ app.get('/api/line-of-sight', async (req, res) => { try { // Generate path points for visualization and spatial query const pathPoints = []; - const totalDistance = 10000; - const steps = 20; + const totalDistance = 20000; + const steps = 40; for (let i = 0; i <= steps; i++) { const dist = (totalDistance * i) / steps; @@ -65,7 +65,8 @@ app.get('/api/line-of-sight', async (req, res) => { const query = ` WITH path AS ( - SELECT ST_GeogFromText($1) as route + SELECT ST_GeogFromText($1) as route, + ST_MakePoint($3, $4)::geography as start_node ) SELECT id, @@ -74,15 +75,21 @@ app.get('/api/line-of-sight', async (req, res) => { country, ST_Y(geom::geometry) as lat, ST_X(geom::geometry) as lon, - ST_Distance(geom, (SELECT route FROM path)) / 1000 as distance_to_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_LineLocatePoint((SELECT route FROM path)::geometry, geom::geometry) as pos_on_line FROM cities WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000) ORDER BY pos_on_line ASC - LIMIT 20; + LIMIT 100; `; - const result = await pool.query(query, [lineWKT, toleranceKm]); + const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]); + + console.log(`Found ${result.rows.length} conurbations near the line.`); + if (result.rows.length > 0) { + console.log('Sample result row:', result.rows[0]); + } res.json({ success: true, @@ -92,7 +99,10 @@ app.get('/api/line-of-sight', async (req, res) => { tolerance_km: toleranceKm, conurbations: result.rows.map(row => ({ ...row, - distance_km: Math.round(row.distance_to_line_km) + name: row.name || 'Unknown', + country: row.country || 'Unknown', + distance_km: Math.round(row.distance_from_start_km), + off_line_km: Math.round(row.distance_off_line_km) })), line_coordinates: pathPoints } diff --git a/backend/scripts/import_cities.js b/backend/scripts/import_cities.js index 5aa2e4b..c4279df 100644 --- a/backend/scripts/import_cities.js +++ b/backend/scripts/import_cities.js @@ -6,7 +6,7 @@ const DATA_URL = 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector async function importCities() { const client = new Client({ - connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@localhost:5432/line_of_sight' + connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight' }); try { @@ -19,6 +19,11 @@ async function importCities() { console.log(`Downloaded ${data.features.length} features. Preparing database...`); + // Sample properties to verify structure + if (data.features.length > 0) { + console.log('Sample properties:', data.features[0].properties); + } + // Ensure table exists and is clean await client.query('TRUNCATE TABLE cities'); @@ -28,40 +33,28 @@ async function importCities() { for (let i = 0; i < data.features.length; i += batchSize) { const batch = data.features.slice(i, i + batchSize); - const values = []; const queryParts = []; - - batch.forEach((feature, index) => { - const props = feature.properties; - const coords = feature.geometry.coordinates; // [lon, lat] - - const name = props.NAME || 'Unknown'; - const population = props.POP_MAX || 0; - const country = props.ADM0NAME || 'Unknown'; - const lon = coords[0]; - const lat = coords[1]; - - const baseIndex = index * 4; - queryParts.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, ST_SetSRID(ST_MakePoint($${baseIndex + 4}, $${baseIndex + 1}), 4326)::geography)`); - values.push(lat, name, population, country, lon); // Note: ST_MakePoint takes lon, lat - }); - - // Simple positional mapping for query (lat, name, pop, country, lon) - // Actually let's refine the query to be clearer - const refinedQueryParts = []; - const refinedValues = []; + const values = []; batch.forEach((feature, index) => { const p = feature.properties; const c = feature.geometry.coordinates; + + // Map properties - Natural Earth simple GeoJSON usually has name, pop_max, adm0name + const name = p.name || p.NAME || 'Unknown'; + const pop = p.pop_max || p.POP_MAX || 0; + const country = p.adm0name || p.ADM0NAME || 'Unknown'; + const lon = c[0]; + const lat = c[1]; + const base = index * 5; - refinedQueryParts.push(`($${base + 1}, $${base + 2}, $${base + 3}, ST_SetSRID(ST_MakePoint($${base + 4}, $${base + 5}), 4326)::geography)`); - refinedValues.push(p.NAME || 'Unknown', p.POP_MAX || 0, p.ADM0NAME || 'Unknown', c[0], c[1]); + queryParts.push(`($${base + 1}, $${base + 2}, $${base + 3}, ST_SetSRID(ST_MakePoint($${base + 4}, $${base + 5}), 4326)::geography)`); + values.push(name, pop, country, lon, lat); }); await client.query( - `INSERT INTO cities (name, population, country, geom) VALUES ${refinedQueryParts.join(',')}`, - refinedValues + `INSERT INTO cities (name, population, country, geom) VALUES ${queryParts.join(',')}`, + values ); count += batch.length; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cbef092..d0acf89 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ const APP = () => { const [loading, setLoading] = useState(false); const [mapStyle, setMapStyle] = useState('light'); // 'light' or 'dark' const [tolerance, setTolerance] = useState(50); + const [selectedCity, setSelectedCity] = useState(null); useEffect(() => { // Initialize MapLibre map @@ -158,6 +159,7 @@ const APP = () => { const handleShowLineOfSight = async () => { setLoading(true); + setSelectedCity(null); try { const response = await apiService.getLineOfSight( selectedPoint.lat, @@ -217,15 +219,16 @@ const APP = () => { // Add city markers data.conurbations.forEach((city, index) => { - const markerId = `city-${index}`; + const displayIndex = index + 1; // Create marker element const el = document.createElement('div'); el.className = 'city-marker'; el.innerHTML = ` +
${displayIndex}
${city.name}
-
${(city.population / 1000000).toFixed(1)}M
+
${(city.population / 1000).toFixed(0)}k
`; // Add marker to map @@ -249,7 +252,43 @@ const APP = () => { return (
-
+
+ {selectedCity && ( +
+
+ +

{selectedCity.name}

+
+
+ + {selectedCity.country} +
+
+ + {selectedCity.population.toLocaleString()} +
+
+ + {selectedCity.lat.toFixed(4)}, {selectedCity.lon.toFixed(4)} +
+
+ + {selectedCity.distance_km} km +
+
+ + {selectedCity.off_line_km} km +
+
+ +
+
+ )} +
@@ -302,28 +341,33 @@ const APP = () => { {lineOfSightData && (

Conurbations Found ({lineOfSightData.conurbations.length})

+

Click a city for details

- + - {lineOfSightData.conurbations.slice(0, 10).map((city, index) => ( - + {lineOfSightData.conurbations.slice(0, 50).map((city, index) => ( + setSelectedCity(city)} + className={selectedCity?.id === city.id ? 'selected-row' : ''} + > - - + + ))}
# City PopulationDistanceDist.
{index + 1} {city.name}{(city.population / 1000000).toFixed(1)}M{city.distance_km} km{(city.population / 1000).toFixed(0)}k{city.distance_km}km
- {lineOfSightData.conurbations.length > 10 && ( -

... and {lineOfSightData.conurbations.length - 10} more cities

+ {lineOfSightData.conurbations.length > 50 && ( +

... and {lineOfSightData.conurbations.length - 50} more

)}
)} diff --git a/frontend/src/styles/App.css b/frontend/src/styles/App.css index 8a8bac3..302b60e 100644 --- a/frontend/src/styles/App.css +++ b/frontend/src/styles/App.css @@ -3,6 +3,7 @@ height: 100vh; width: 100vw; overflow: hidden; + position: relative; } .map-container { @@ -19,6 +20,7 @@ box-shadow: -2px 0 10px rgba(0,0,0,0.1); overflow-y: auto; z-index: 2; + position: relative; } .control-group { @@ -62,6 +64,7 @@ .setting-row span { font-weight: bold; color: #2c3e50; + min-width: 40px; } .setting-row button { @@ -112,7 +115,14 @@ margin-top: 0; color: #2c3e50; font-size: 16px; - margin-bottom: 15px; + margin-bottom: 5px; +} + +.results-panel .hint { + font-size: 11px; + color: #7f8c8d; + margin-bottom: 10px; + font-style: italic; } .results-panel table { @@ -124,21 +134,114 @@ .results-panel th { background: #34495e; color: white; - padding: 10px; + padding: 8px 4px; text-align: left; } .results-panel td { - padding: 8px; + padding: 8px 4px; border-bottom: 1px solid #ddd; + cursor: pointer; } .results-panel tr:nth-child(even) { - background: #f8f9fa; + background: #fdfdfd; } .results-panel tr:hover { - background: #e9ecef; + background: #ecf0f1; +} + +.results-panel tr.selected-row { + background: #d5e8d4 !important; + font-weight: bold; +} + +.info-panel-modal { + position: absolute; + top: 20px; + left: 20px; + width: 280px; + background: white; + border-radius: 8px; + box-shadow: 0 4px 15px rgba(0,0,0,0.3); + z-index: 1000; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { opacity: 0; transform: translateX(-20px); } + to { opacity: 1; transform: translateX(0); } +} + +.info-panel-content { + padding: 20px; + position: relative; +} + +.info-panel-content h2 { + margin-top: 0; + margin-bottom: 15px; + font-size: 18px; + color: #2c3e50; + border-bottom: 2px solid #4CAF50; + padding-bottom: 8px; +} + +.close-btn { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #95a5a6; +} + +.close-btn:hover { + color: #34495e; +} + +.info-grid { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.info-item { + display: flex; + flex-direction: column; +} + +.info-item label { + font-size: 11px; + color: #7f8c8d; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.info-item span { + font-size: 14px; + color: #2c3e50; + font-weight: 500; +} + +.action-btn-small { + width: 100%; + padding: 8px; + background: #3498db; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} + +.action-btn-small:hover { + background: #2980b9; } .more-info { @@ -177,6 +280,23 @@ flex-direction: column; align-items: center; font-family: sans-serif; + z-index: 100; +} + +.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 { @@ -186,6 +306,7 @@ border: 2px solid white; border-radius: 50%; box-shadow: 0 2px 4px rgba(0,0,0,0.3); + z-index: 1; } .marker-label { @@ -223,4 +344,12 @@ .map-container { height: 60vh; } + + .info-panel-modal { + left: 10px; + right: 10px; + width: auto; + bottom: 20px; + top: auto; + } }