feature/modernize-and-enhance #4
@@ -81,7 +81,7 @@ app.get('/api/line-of-sight', async (req, res) => {
|
|||||||
FROM cities
|
FROM cities
|
||||||
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
|
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
|
||||||
ORDER BY pos_on_line ASC
|
ORDER BY pos_on_line ASC
|
||||||
LIMIT 100;
|
LIMIT 200;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
|
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
|
||||||
|
|||||||
@@ -1,75 +1,95 @@
|
|||||||
const { Client } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const axios = require('axios');
|
const { execSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const readline = require('readline');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const DATA_URL = 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson';
|
const DATA_URL = 'https://download.geonames.org/export/dump/cities5000.zip';
|
||||||
|
const ZIP_FILE = '/tmp/cities.zip';
|
||||||
|
const TXT_FILE = '/tmp/cities5000.txt';
|
||||||
|
|
||||||
async function importCities() {
|
async function importGeoNames() {
|
||||||
const client = new Client({
|
const pool = new Pool({
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight'
|
connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight'
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('Downloading GeoNames cities5000 (Pop > 5000)...');
|
||||||
|
execSync(`wget -q ${DATA_URL} -O ${ZIP_FILE}`);
|
||||||
|
|
||||||
|
console.log('Extracting data...');
|
||||||
|
execSync(`unzip -o ${ZIP_FILE} -d /tmp`);
|
||||||
|
|
||||||
console.log('Connecting to database...');
|
console.log('Connecting to database...');
|
||||||
await client.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
console.log('Downloading Natural Earth data (GeoJSON)...');
|
// Ensure table is clean
|
||||||
const response = await axios.get(DATA_URL);
|
|
||||||
const data = response.data;
|
|
||||||
|
|
||||||
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');
|
await client.query('TRUNCATE TABLE cities');
|
||||||
|
|
||||||
|
console.log('Starting stream import...');
|
||||||
|
|
||||||
|
const fileStream = fs.createReadStream(TXT_FILE);
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity
|
||||||
|
});
|
||||||
|
|
||||||
|
let batch = [];
|
||||||
|
const batchSize = 500;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const batchSize = 100;
|
|
||||||
|
|
||||||
for (let i = 0; i < data.features.length; i += batchSize) {
|
for await (const line of rl) {
|
||||||
const batch = data.features.slice(i, i + batchSize);
|
const parts = line.split('\t');
|
||||||
|
if (parts.length < 15) continue;
|
||||||
|
|
||||||
|
const name = parts[1]; // name
|
||||||
|
const lat = parseFloat(parts[4]);
|
||||||
|
const lon = parseFloat(parts[5]);
|
||||||
|
const country = parts[8]; // country code
|
||||||
|
const population = parseInt(parts[14]) || 0;
|
||||||
|
|
||||||
|
batch.push({ name, lat, lon, country, population });
|
||||||
|
|
||||||
|
if (batch.length >= batchSize) {
|
||||||
|
await insertBatch(client, batch);
|
||||||
|
count += batch.length;
|
||||||
|
if (count % 5000 === 0) console.log(`Imported ${count} cities...`);
|
||||||
|
batch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.length > 0) {
|
||||||
|
await insertBatch(client, batch);
|
||||||
|
count += batch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`SUCCESS: Imported ${count} cities and towns.`);
|
||||||
|
client.release();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('ERROR during import:', err);
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
if (fs.existsSync(ZIP_FILE)) fs.unlinkSync(ZIP_FILE);
|
||||||
|
if (fs.existsSync(TXT_FILE)) fs.unlinkSync(TXT_FILE);
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertBatch(client, batch) {
|
||||||
const queryParts = [];
|
const queryParts = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
|
|
||||||
batch.forEach((feature, index) => {
|
batch.forEach((city, 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;
|
const base = index * 5;
|
||||||
queryParts.push(`($${base + 1}, $${base + 2}, $${base + 3}, ST_SetSRID(ST_MakePoint($${base + 4}, $${base + 5}), 4326)::geography)`);
|
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);
|
values.push(city.name, city.population, city.country, city.lon, city.lat);
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO cities (name, population, country, geom) VALUES ${queryParts.join(',')}`,
|
`INSERT INTO cities (name, population, country, geom) VALUES ${queryParts.join(',')}`,
|
||||||
values
|
values
|
||||||
);
|
);
|
||||||
|
|
||||||
count += batch.length;
|
|
||||||
if (count % 1000 === 0 || count === data.features.length) {
|
|
||||||
console.log(`Imported ${count}/${data.features.length} cities...`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('SUCCESS: Natural Earth data import complete.');
|
importGeoNames();
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('ERROR during import:', err);
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
importCities();
|
|
||||||
|
|||||||
Generated
+2162
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@turf/turf": "^7.3.4",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"maplibre-gl": "^5.20.1",
|
"maplibre-gl": "^5.20.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
|
|||||||
+88
-19
@@ -16,20 +16,32 @@ const APP = () => {
|
|||||||
const [mapStyle, setMapStyle] = useState('light'); // 'light' or 'dark'
|
const [mapStyle, setMapStyle] = useState('light'); // 'light' or 'dark'
|
||||||
const [tolerance, setTolerance] = useState(50);
|
const [tolerance, setTolerance] = useState(50);
|
||||||
const [selectedCity, setSelectedCity] = useState(null);
|
const [selectedCity, setSelectedCity] = useState(null);
|
||||||
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialize MapLibre map
|
// Initialize MapLibre map - ONLY ONCE
|
||||||
mapRef.current = new maplibregl.Map({
|
mapRef.current = new maplibregl.Map({
|
||||||
container: mapContainerRef.current,
|
container: mapContainerRef.current,
|
||||||
style: getMapStyle(mapStyle),
|
style: getMapStyle(mapStyle),
|
||||||
center: [-0.1278, 51.5074], // London
|
center: [-0.1278, 51.5074], // London
|
||||||
zoom: 3,
|
zoom: 2,
|
||||||
pitch: 0
|
pitch: 0,
|
||||||
|
projection: 'globe' // Enable 3D Globe
|
||||||
});
|
});
|
||||||
|
|
||||||
mapRef.current.on('load', () => {
|
mapRef.current.on('load', () => {
|
||||||
console.log('Map loaded successfully');
|
console.log('Map loaded successfully');
|
||||||
|
|
||||||
|
// Add atmosphere/sky effects (MapLibre v5 uses setSky)
|
||||||
|
mapRef.current.setSky({
|
||||||
|
'sky-color': '#199EF3',
|
||||||
|
'sky-horizon-blend': 0.5,
|
||||||
|
'horizon-color': '#ffffff',
|
||||||
|
'horizon-fog-blend': 0.5,
|
||||||
|
'fog-color': '#add8e6',
|
||||||
|
'fog-ground-blend': 0.5
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize start marker
|
// Initialize start marker
|
||||||
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
|
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
|
||||||
.setLngLat([selectedPoint.lon, selectedPoint.lat])
|
.setLngLat([selectedPoint.lon, selectedPoint.lat])
|
||||||
@@ -61,7 +73,19 @@ const APP = () => {
|
|||||||
updatePreviewLine(selectedPoint, direction);
|
updatePreviewLine(selectedPoint, direction);
|
||||||
});
|
});
|
||||||
|
|
||||||
mapRef.current.on('click', (e) => {
|
return () => {
|
||||||
|
if (mapRef.current) mapRef.current.remove();
|
||||||
|
};
|
||||||
|
}, []); // Run only once
|
||||||
|
|
||||||
|
// Separate effect for the click listener that respects isLocked
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
// Don't allow moving start point if locked
|
||||||
|
if (isLocked) return;
|
||||||
|
|
||||||
const { lng, lat } = e.lngLat;
|
const { lng, lat } = e.lngLat;
|
||||||
setSelectedPoint({ lat, lon: lng });
|
setSelectedPoint({ lat, lon: lng });
|
||||||
|
|
||||||
@@ -75,12 +99,31 @@ const APP = () => {
|
|||||||
mapRef.current.removeLayer('line-of-sight');
|
mapRef.current.removeLayer('line-of-sight');
|
||||||
mapRef.current.removeSource('line-of-sight');
|
mapRef.current.removeSource('line-of-sight');
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mapRef.current.remove();
|
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
mapRef.current.on('click', handleClick);
|
||||||
|
return () => {
|
||||||
|
if (mapRef.current) mapRef.current.off('click', handleClick);
|
||||||
|
};
|
||||||
|
}, [isLocked]);
|
||||||
|
|
||||||
|
const handleStartAgain = () => {
|
||||||
|
setIsLocked(false);
|
||||||
|
setLineOfSightData(null);
|
||||||
|
setSelectedCity(null);
|
||||||
|
clearCityMarkers();
|
||||||
|
|
||||||
|
if (mapRef.current) {
|
||||||
|
if (mapRef.current.getSource('line-of-sight')) {
|
||||||
|
mapRef.current.removeLayer('line-of-sight');
|
||||||
|
mapRef.current.removeSource('line-of-sight');
|
||||||
|
}
|
||||||
|
// Re-enable preview line if it was hidden
|
||||||
|
if (mapRef.current.getLayer('preview-line')) {
|
||||||
|
mapRef.current.setLayoutProperty('preview-line', 'visibility', 'visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const clearCityMarkers = () => {
|
const clearCityMarkers = () => {
|
||||||
if (cityMarkersRef.current) {
|
if (cityMarkersRef.current) {
|
||||||
@@ -100,10 +143,10 @@ const APP = () => {
|
|||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !map.getSource('preview-line')) return;
|
if (!map || !map.getSource('preview-line')) return;
|
||||||
|
|
||||||
// Generate 50 points along a 5000km great circle path for a smooth curve
|
// Generate 50 points along a 20,000km great circle path for a smooth curve
|
||||||
const path = [];
|
const path = [];
|
||||||
const steps = 50;
|
const steps = 50;
|
||||||
const totalDistance = 5000;
|
const totalDistance = 20000;
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
for (let i = 0; i <= steps; i++) {
|
||||||
const dist = (totalDistance * i) / steps;
|
const dist = (totalDistance * i) / steps;
|
||||||
@@ -169,6 +212,13 @@ const APP = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setLineOfSightData(response.data.data);
|
setLineOfSightData(response.data.data);
|
||||||
|
setIsLocked(true); // Lock the map after showing results
|
||||||
|
|
||||||
|
// Hide the preview line when showing final results
|
||||||
|
if (mapRef.current && mapRef.current.getLayer('preview-line')) {
|
||||||
|
mapRef.current.setLayoutProperty('preview-line', 'visibility', 'none');
|
||||||
|
}
|
||||||
|
|
||||||
renderLineOnMap(response.data.data);
|
renderLineOnMap(response.data.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching line of sight:', error);
|
console.error('Error fetching line of sight:', error);
|
||||||
@@ -234,11 +284,13 @@ const APP = () => {
|
|||||||
// Add marker to map
|
// Add marker to map
|
||||||
const marker = new maplibregl.Marker(el)
|
const marker = new maplibregl.Marker(el)
|
||||||
.setLngLat([city.lon, city.lat])
|
.setLngLat([city.lon, city.lat])
|
||||||
.setPopup(new maplibregl.Popup().setHTML(
|
|
||||||
`<strong>${city.name}</strong><br/>Population: ${city.population.toLocaleString()}<br/>Distance: ${city.distance_km} km`
|
|
||||||
))
|
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
|
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); // Prevent map click
|
||||||
|
setSelectedCity(city);
|
||||||
|
});
|
||||||
|
|
||||||
cityMarkersRef.current.push(marker);
|
cityMarkersRef.current.push(marker);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,7 +343,7 @@ const APP = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="controls">
|
<div className="controls">
|
||||||
<div className="control-group">
|
<div className={`control-group ${isLocked ? 'disabled-controls' : ''}`}>
|
||||||
<h3>Line of Sight Settings</h3>
|
<h3>Line of Sight Settings</h3>
|
||||||
|
|
||||||
<div className="setting-row">
|
<div className="setting-row">
|
||||||
@@ -306,6 +358,7 @@ const APP = () => {
|
|||||||
min="0"
|
min="0"
|
||||||
max="360"
|
max="360"
|
||||||
value={direction}
|
value={direction}
|
||||||
|
disabled={isLocked}
|
||||||
onChange={(e) => setDirection(parseInt(e.target.value))}
|
onChange={(e) => setDirection(parseInt(e.target.value))}
|
||||||
/>
|
/>
|
||||||
<span>{direction}°</span>
|
<span>{direction}°</span>
|
||||||
@@ -316,6 +369,7 @@ const APP = () => {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={tolerance}
|
value={tolerance}
|
||||||
|
disabled={isLocked}
|
||||||
onChange={(e) => setTolerance(parseInt(e.target.value))}
|
onChange={(e) => setTolerance(parseInt(e.target.value))}
|
||||||
min="10"
|
min="10"
|
||||||
max="200"
|
max="200"
|
||||||
@@ -329,6 +383,7 @@ const APP = () => {
|
|||||||
<button onClick={() => setMapStyle('dark')}>Dark</button>
|
<button onClick={() => setMapStyle('dark')}>Dark</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isLocked ? (
|
||||||
<button
|
<button
|
||||||
className="action-btn"
|
className="action-btn"
|
||||||
onClick={handleShowLineOfSight}
|
onClick={handleShowLineOfSight}
|
||||||
@@ -336,6 +391,23 @@ const APP = () => {
|
|||||||
>
|
>
|
||||||
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
|
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={startFlyOver}
|
||||||
|
style={{ backgroundColor: isPlaying ? '#e74c3c' : '#3498db' }}
|
||||||
|
>
|
||||||
|
{isPlaying ? '⏹ Stop Flight' : '✈️ Fly Over Route'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn-secondary"
|
||||||
|
onClick={handleStartAgain}
|
||||||
|
>
|
||||||
|
🔄 Start Again
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lineOfSightData && (
|
{lineOfSightData && (
|
||||||
@@ -352,7 +424,7 @@ const APP = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{lineOfSightData.conurbations.slice(0, 50).map((city, index) => (
|
{lineOfSightData.conurbations.map((city, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={city.id}
|
key={city.id}
|
||||||
onClick={() => setSelectedCity(city)}
|
onClick={() => setSelectedCity(city)}
|
||||||
@@ -366,9 +438,6 @@ const APP = () => {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{lineOfSightData.conurbations.length > 50 && (
|
|
||||||
<p className="more-info">... and {lineOfSightData.conurbations.length - 50} more</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -104,11 +104,55 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-secondary:hover {
|
||||||
|
background: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-controls {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-controls input, .disabled-controls .action-btn {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-secondary {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row button {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.results-panel {
|
.results-panel {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
max-height: 450px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel h3 {
|
.results-panel h3 {
|
||||||
|
|||||||
Reference in New Issue
Block a user