Merge pull request 'feature/modernize-and-enhance' (#4) from feature/modernize-and-enhance into main
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
+114
-50
@@ -1,66 +1,130 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Database connection pool
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight'
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Mock conurbation data for MVP
|
||||
const MOCK_CONURBATIONS = [
|
||||
{ id: 1, name: "London", population: 9000000, distance_km: 0, lat: 51.5074, lon: -0.1278 },
|
||||
{ id: 2, name: "Paris", population: 2161000, distance_km: 344, lat: 48.8566, lon: 2.3522 },
|
||||
{ id: 3, name: "Berlin", population: 3644000, distance_km: 878, lat: 52.5200, lon: 13.4050 },
|
||||
{ id: 4, name: "Warsaw", population: 1793000, distance_km: 1200, lat: 52.2297, lon: 21.0122 },
|
||||
{ id: 5, name: "Moscow", population: 12506000, distance_km: 2063, lat: 55.7558, lon: 37.6173 },
|
||||
{ id: 6, name: "Kazan", population: 1257000, distance_km: 2850, lat: 55.7897, lon: 49.1219 },
|
||||
{ id: 7, name: "Almaty", population: 2000000, distance_km: 3900, lat: 43.2220, lon: 76.8512 },
|
||||
{ id: 8, name: "Urumqi", population: 3500000, distance_km: 4500, lat: 43.8256, lon: 87.6168 },
|
||||
{ id: 9, name: "Lahore", population: 11126000, distance_km: 5400, lat: 31.5204, lon: 74.3587 },
|
||||
{ id: 10, name: "New Delhi", population: 29399000, distance_km: 5800, lat: 28.6139, lon: 77.2090 },
|
||||
{ id: 11, name: "Dhaka", population: 21006000, distance_km: 6200, lat: 23.8103, lon: 90.4125 },
|
||||
{ id: 12, name: "Chennai", population: 10971000, distance_km: 6500, lat: 13.0827, lon: 80.2707 },
|
||||
{ id: 13, name: "Bangkok", population: 10539000, distance_km: 7200, lat: 13.7563, lon: 100.5018 },
|
||||
{ id: 14, name: "Jakarta", population: 10562000, distance_km: 8100, lat: -6.2088, lon: 106.8456 },
|
||||
{ id: 15, name: "Singapore", population: 5686000, distance_km: 8300, lat: 1.3521, lon: 103.8198 },
|
||||
{ id: 16, name: "Manila", population: 17801000, distance_km: 8700, lat: 14.5995, lon: 120.9842 },
|
||||
{ id: 17, name: "Tokyo", population: 37400000, distance_km: 9500, lat: 35.6762, lon: 139.6503 },
|
||||
{ id: 18, name: "Seoul", population: 9720000, distance_km: 9200, lat: 37.5665, lon: 126.9780 },
|
||||
{ id: 19, name: "Beijing", population: 21540000, distance_km: 8900, lat: 39.9042, lon: 116.4074 },
|
||||
{ id: 20, name: "Shanghai", population: 27058000, distance_km: 9000, lat: 31.2304, lon: 121.4737 }
|
||||
];
|
||||
// Helper to calculate destination point given start, bearing, and distance (km)
|
||||
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||
const R = 6371;
|
||||
const brng = (bearing * Math.PI) / 180;
|
||||
const φ1 = (lat * Math.PI) / 180;
|
||||
const λ1 = (lon * Math.PI) / 180;
|
||||
const δ = distance / R;
|
||||
|
||||
// Mock API endpoint - returns dummy conurbations based on input coordinates
|
||||
app.get('/api/line-of-sight', (req, res) => {
|
||||
const φ2 = Math.asin(
|
||||
Math.sin(φ1) * Math.cos(δ) +
|
||||
Math.cos(φ1) * Math.sin(δ) * Math.cos(brng)
|
||||
);
|
||||
const λ2 =
|
||||
λ1 +
|
||||
Math.atan2(
|
||||
Math.sin(brng) * Math.sin(δ) * Math.cos(φ1),
|
||||
Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2)
|
||||
);
|
||||
|
||||
return {
|
||||
lat: (φ2 * 180) / Math.PI,
|
||||
lon: (((λ2 * 180) / Math.PI + 540) % 360) - 180
|
||||
};
|
||||
};
|
||||
|
||||
// Real API endpoint - uses PostGIS for spatial queries
|
||||
app.get('/api/line-of-sight', async (req, res) => {
|
||||
const { lat, lon, direction, tolerance } = req.query;
|
||||
|
||||
console.log(`Received request: lat=${lat}, lon=${lon}, direction=${direction}, tolerance=${tolerance}`);
|
||||
|
||||
// Return mock data for MVP
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
start_point: { lat: parseFloat(lat) || 51.5074, lon: parseFloat(lon) || -0.1278 },
|
||||
direction: parseInt(direction) || 45,
|
||||
tolerance_km: parseInt(tolerance) || 50,
|
||||
conurbations: MOCK_CONURBATIONS.slice(0, 20),
|
||||
line_coordinates: [
|
||||
{ lat: 51.5074, lon: -0.1278 },
|
||||
{ lat: 48.8566, lon: 2.3522 },
|
||||
{ lat: 52.5200, lon: 13.4050 },
|
||||
{ lat: 55.7558, lon: 37.6173 },
|
||||
{ lat: 43.2220, lon: 76.8512 },
|
||||
{ lat: 28.6139, lon: 77.2090 },
|
||||
{ lat: 13.7563, lon: 100.5018 },
|
||||
{ lat: -6.2088, lon: 106.8456 },
|
||||
{ lat: 35.6762, lon: 139.6503 },
|
||||
{ lat: 51.5074, lon: -0.1278 } // Complete the circle
|
||||
]
|
||||
},
|
||||
message: "Mock data returned for MVP - Real geospatial calculations coming soon"
|
||||
});
|
||||
const startLat = parseFloat(lat) || 51.5074;
|
||||
const startLon = parseFloat(lon) || -0.1278;
|
||||
const bearing = parseInt(direction) || 0;
|
||||
const toleranceKm = parseInt(tolerance) || 50;
|
||||
|
||||
console.log(`Processing real request: lat=${startLat}, lon=${startLon}, bearing=${bearing}, tolerance=${toleranceKm}`);
|
||||
|
||||
try {
|
||||
// Generate path points for visualization and spatial query
|
||||
const pathPoints = [];
|
||||
const totalDistance = 20000;
|
||||
const steps = 80; // More steps for smoother speed transition
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const dist = (totalDistance * i) / steps;
|
||||
pathPoints.push(calculateDestination(startLat, startLon, bearing, dist));
|
||||
}
|
||||
|
||||
// Batch check for 'over water' status for all path points
|
||||
// We'll consider a point 'over water' if no city is within 500km
|
||||
const waterChecks = await Promise.all(pathPoints.map(async (p) => {
|
||||
const checkQuery = `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM cities
|
||||
WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 500000)
|
||||
LIMIT 1
|
||||
) as has_land;
|
||||
`;
|
||||
const res = await pool.query(checkQuery, [p.lon, p.lat]);
|
||||
return !res.rows[0].has_land;
|
||||
}));
|
||||
|
||||
const pathPointsWithWater = pathPoints.map((p, i) => ({
|
||||
...p,
|
||||
is_over_water: waterChecks[i]
|
||||
}));
|
||||
|
||||
const lineWKT = `LINESTRING(${pathPoints.map(p => `${p.lon} ${p.lat}`).join(',')})`;
|
||||
|
||||
const query = `
|
||||
WITH path AS (
|
||||
SELECT ST_GeogFromText($1) as route,
|
||||
ST_MakePoint($3, $4)::geography as start_node
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
population,
|
||||
country,
|
||||
ST_Y(geom::geometry) as lat,
|
||||
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
|
||||
FROM cities
|
||||
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
|
||||
ORDER BY pos_on_line ASC
|
||||
LIMIT 200;
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
start_point: { lat: startLat, lon: startLon },
|
||||
direction: bearing,
|
||||
tolerance_km: toleranceKm,
|
||||
conurbations: result.rows.map(row => ({
|
||||
...row,
|
||||
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: pathPointsWithWater
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Database query error:', err);
|
||||
res.status(500).json({ success: false, error: 'Database query failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
|
||||
Generated
+132
@@ -8,6 +8,7 @@
|
||||
"name": "line-of-sight-backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
@@ -1426,6 +1427,23 @@
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "30.3.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
|
||||
@@ -1891,6 +1909,18 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -2008,6 +2038,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -2128,6 +2167,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -2329,6 +2383,26 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -2345,6 +2419,43 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -2533,6 +2644,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -4158,6 +4284,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
|
||||
@@ -6,16 +6,18 @@
|
||||
"scripts": {
|
||||
"start": "node app/server.js",
|
||||
"dev": "nodemon app/server.js",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"seed-data": "node scripts/import_cities.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"axios": "^1.13.6",
|
||||
"cors": "^2.8.6",
|
||||
"pg": "^8.20.0",
|
||||
"dotenv": "^17.3.1"
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"pg": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14",
|
||||
"jest": "^30.3.0"
|
||||
"jest": "^30.3.0",
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
const { Pool } = require('pg');
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
require('dotenv').config();
|
||||
|
||||
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 importGeoNames() {
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight'
|
||||
});
|
||||
|
||||
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...');
|
||||
const client = await pool.connect();
|
||||
|
||||
// Ensure table is clean
|
||||
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;
|
||||
|
||||
for await (const line of rl) {
|
||||
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 values = [];
|
||||
|
||||
batch.forEach((city, index) => {
|
||||
const base = index * 5;
|
||||
queryParts.push(`($${base + 1}, $${base + 2}, $${base + 3}, ST_SetSRID(ST_MakePoint($${base + 4}, $${base + 5}), 4326)::geography)`);
|
||||
values.push(city.name, city.population, city.country, city.lon, city.lat);
|
||||
});
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO cities (name, population, country, geom) VALUES ${queryParts.join(',')}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
importGeoNames();
|
||||
Generated
+2162
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@turf/turf": "^7.3.4",
|
||||
"axios": "^1.7.9",
|
||||
"maplibre-gl": "^5.20.1",
|
||||
"react": "^19.2.1",
|
||||
|
||||
+475
-122
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import * as turf from '@turf/turf';
|
||||
import apiService from './services/api';
|
||||
import './styles/App.css';
|
||||
|
||||
@@ -8,33 +9,77 @@ 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);
|
||||
const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 });
|
||||
const [direction, setDirection] = useState(45);
|
||||
const [lineOfSightData, setLineOfSightData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [flightSpeed, setFlightSpeed] = useState(1.0);
|
||||
const [mapStyle, setMapStyle] = useState('light'); // 'light' or 'dark'
|
||||
const [tolerance, setTolerance] = useState(50);
|
||||
const [selectedCity, setSelectedCity] = useState(null);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize MapLibre map
|
||||
// Initialize MapLibre map - ONLY ONCE
|
||||
mapRef.current = new maplibregl.Map({
|
||||
container: mapContainerRef.current,
|
||||
style: getMapStyle(mapStyle),
|
||||
center: [-0.1278, 51.5074], // London
|
||||
zoom: 3,
|
||||
pitch: 0
|
||||
zoom: 2,
|
||||
pitch: 0,
|
||||
projection: 'globe' // Enable 3D Globe
|
||||
});
|
||||
|
||||
mapRef.current.on('load', () => {
|
||||
console.log('Map loaded successfully');
|
||||
|
||||
// Initialize start marker
|
||||
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
|
||||
.setLngLat([selectedPoint.lon, selectedPoint.lat])
|
||||
.addTo(mapRef.current);
|
||||
mapRef.current.on('style.load', () => {
|
||||
console.log('Map style loaded or changed');
|
||||
setupMapLayers();
|
||||
});
|
||||
|
||||
// Initialize preview line source and layer
|
||||
mapRef.current.addSource('preview-line', {
|
||||
return () => {
|
||||
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||
if (mapRef.current) mapRef.current.remove();
|
||||
};
|
||||
}, []); // Run only once
|
||||
|
||||
const setupMapLayers = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
// 1. Add atmosphere/sky effects
|
||||
map.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
|
||||
});
|
||||
|
||||
// 2. Add terrain source for 3D effect
|
||||
if (!map.getSource('terrain')) {
|
||||
map.addSource('terrain', {
|
||||
type: 'raster-dem',
|
||||
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
|
||||
encoding: 'terrarium',
|
||||
tileSize: 256,
|
||||
maxzoom: 15
|
||||
});
|
||||
}
|
||||
map.setTerrain({ source: 'terrain', exaggeration: 1.5 });
|
||||
|
||||
// 3. Initialize start marker
|
||||
if (startMarkerRef.current) startMarkerRef.current.remove();
|
||||
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
|
||||
.setLngLat([selectedPoint.lon, selectedPoint.lat])
|
||||
.addTo(map);
|
||||
|
||||
// 4. Initialize preview line source and layer
|
||||
if (!map.getSource('preview-line')) {
|
||||
map.addSource('preview-line', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
@@ -45,7 +90,7 @@ const APP = () => {
|
||||
}
|
||||
});
|
||||
|
||||
mapRef.current.addLayer({
|
||||
map.addLayer({
|
||||
id: 'preview-line',
|
||||
type: 'line',
|
||||
source: 'preview-line',
|
||||
@@ -55,11 +100,28 @@ const APP = () => {
|
||||
'line-dasharray': [2, 2]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Restore results line and city markers if they existed
|
||||
if (lineOfSightData) {
|
||||
renderLineOnMap(lineOfSightData);
|
||||
// Hide preview if results are shown
|
||||
if (map.getLayer('preview-line')) {
|
||||
map.setLayoutProperty('preview-line', 'visibility', 'none');
|
||||
}
|
||||
} else {
|
||||
updatePreviewLine(selectedPoint, direction);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
mapRef.current.on('click', (e) => {
|
||||
const { lng, lat } = e.lngLat;
|
||||
setSelectedPoint({ lat, lon: lng });
|
||||
|
||||
@@ -67,33 +129,329 @@ const APP = () => {
|
||||
startMarkerRef.current.setLngLat([lng, lat]);
|
||||
}
|
||||
|
||||
// Clear previous final line when moving start point
|
||||
// Clear previous final results when moving start point
|
||||
clearCityMarkers();
|
||||
if (mapRef.current.getSource('line-of-sight')) {
|
||||
mapRef.current.removeLayer('line-of-sight');
|
||||
mapRef.current.removeSource('line-of-sight');
|
||||
}
|
||||
};
|
||||
|
||||
mapRef.current.on('click', handleClick);
|
||||
return () => {
|
||||
if (mapRef.current) mapRef.current.off('click', handleClick);
|
||||
};
|
||||
}, [isLocked]);
|
||||
|
||||
const handleStartAgain = () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
setIsPlaying(false);
|
||||
setFlightSpeed(1.0);
|
||||
}
|
||||
|
||||
if (popupRef.current) {
|
||||
popupRef.current.remove();
|
||||
popupRef.current = null;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// Reset map pitch and zoom
|
||||
mapRef.current.flyTo({
|
||||
center: [selectedPoint.lon, selectedPoint.lat],
|
||||
zoom: 3,
|
||||
pitch: 0,
|
||||
bearing: 0
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clearCityMarkers = () => {
|
||||
if (cityMarkersRef.current) {
|
||||
cityMarkersRef.current.forEach(marker => marker.remove());
|
||||
cityMarkersRef.current = [];
|
||||
}
|
||||
};
|
||||
|
||||
const getCountryName = (code) => {
|
||||
try {
|
||||
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||
return regionNames.of(code.toUpperCase()) || code;
|
||||
} catch (e) {
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
const showCityPopup = (city) => {
|
||||
if (!mapRef.current) return;
|
||||
|
||||
if (popupRef.current) popupRef.current.remove();
|
||||
|
||||
const popupNode = document.createElement('div');
|
||||
popupNode.className = 'map-info-popup';
|
||||
const countryName = getCountryName(city.country);
|
||||
|
||||
popupNode.innerHTML = `
|
||||
<div class="popup-header">
|
||||
<strong>${city.name}</strong>, ${countryName}
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<p>Population: ${city.population.toLocaleString()}</p>
|
||||
<p>Dist. from start: ${city.distance_km} km</p>
|
||||
<p>Deviation: ${city.off_line_km} km</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
popupRef.current = new maplibregl.Popup({
|
||||
closeButton: true,
|
||||
closeOnClick: false,
|
||||
maxWidth: '300px',
|
||||
offset: [0, -20]
|
||||
})
|
||||
.setLngLat([city.lon, city.lat])
|
||||
.setDOMContent(popupNode)
|
||||
.addTo(mapRef.current);
|
||||
|
||||
popupRef.current.on('close', () => {
|
||||
setSelectedCity(null);
|
||||
});
|
||||
};
|
||||
|
||||
const startFlyOver = () => {
|
||||
if (!lineOfSightData || !mapRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||
setIsPlaying(false);
|
||||
setFlightSpeed(1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPlaying(true);
|
||||
setSelectedCity(null);
|
||||
if (popupRef.current) popupRef.current.remove();
|
||||
|
||||
const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]);
|
||||
const route = turf.lineString(coordinates);
|
||||
const routeDistance = turf.length(route);
|
||||
const startTime = performance.now();
|
||||
|
||||
let lastNearestCityId = null;
|
||||
let lastFetchedDist = 0;
|
||||
let currentProgress = 0;
|
||||
let lastTimestamp = performance.now();
|
||||
let currentSpeedMultiplier = 1.0;
|
||||
let frameCount = 0;
|
||||
|
||||
async function animate(currentTime) {
|
||||
if (!mapRef.current) return;
|
||||
|
||||
const deltaTime = currentTime - lastTimestamp;
|
||||
lastTimestamp = currentTime;
|
||||
|
||||
const baseKmPerMs = 0.1; // 100km per second
|
||||
|
||||
const pathPoints = lineOfSightData.line_coordinates;
|
||||
const pointIndex = Math.min(
|
||||
Math.floor((currentProgress / routeDistance) * pathPoints.length),
|
||||
pathPoints.length - 1
|
||||
);
|
||||
const isOverWater = pathPoints[pointIndex]?.is_over_water;
|
||||
|
||||
// Predictive land detection (look ahead 2000km)
|
||||
let futureIsOverWater = true;
|
||||
const lookAheadDistance = 2000;
|
||||
const lookAheadSteps = 10;
|
||||
for (let i = 1; i <= lookAheadSteps; i++) {
|
||||
const checkDist = currentProgress + (lookAheadDistance * i) / lookAheadSteps;
|
||||
const checkIndex = Math.min(
|
||||
Math.floor((checkDist / routeDistance) * pathPoints.length),
|
||||
pathPoints.length - 1
|
||||
);
|
||||
if (pathPoints[checkIndex] && !pathPoints[checkIndex].is_over_water) {
|
||||
futureIsOverWater = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const targetMultiplier = (isOverWater && futureIsOverWater) ? 20.0 : 1.0;
|
||||
const acceleration = 0.005;
|
||||
|
||||
if (currentSpeedMultiplier < targetMultiplier) {
|
||||
currentSpeedMultiplier = Math.min(currentSpeedMultiplier + acceleration * deltaTime, targetMultiplier);
|
||||
} else if (currentSpeedMultiplier > targetMultiplier) {
|
||||
currentSpeedMultiplier = Math.max(currentSpeedMultiplier - acceleration * deltaTime, targetMultiplier);
|
||||
}
|
||||
|
||||
frameCount++;
|
||||
if (frameCount % 10 === 0) {
|
||||
setFlightSpeed(currentSpeedMultiplier);
|
||||
}
|
||||
|
||||
currentProgress += baseKmPerMs * deltaTime * currentSpeedMultiplier;
|
||||
|
||||
const phase = Math.min(currentProgress / routeDistance, 1);
|
||||
|
||||
if (phase >= 1) {
|
||||
setIsPlaying(false);
|
||||
setFlightSpeed(1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
const eye = turf.along(route, currentProgress).geometry.coordinates;
|
||||
const target = turf.along(route, Math.min(currentProgress + 10, routeDistance)).geometry.coordinates;
|
||||
|
||||
const bearing = turf.bearing(turf.point(eye), turf.point(target));
|
||||
|
||||
mapRef.current.jumpTo({
|
||||
center: eye,
|
||||
zoom: 6,
|
||||
pitch: 65,
|
||||
bearing: bearing
|
||||
});
|
||||
|
||||
if (currentProgress - lastFetchedDist > 2000) {
|
||||
lastFetchedDist = currentProgress;
|
||||
try {
|
||||
const response = await apiService.getLineOfSight(
|
||||
eye[1], eye[0], bearing, tolerance
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
const newCities = response.data.data.conurbations;
|
||||
setLineOfSightData(prev => {
|
||||
const existingIds = new Set(prev.conurbations.map(c => c.id));
|
||||
const uniqueNewCities = newCities.filter(c => !existingIds.has(c.id));
|
||||
const adjustedCities = uniqueNewCities.map(c => ({
|
||||
...c,
|
||||
distance_km: Math.round(c.distance_km + currentProgress)
|
||||
}));
|
||||
const updatedData = {
|
||||
...prev,
|
||||
conurbations: [...prev.conurbations, ...adjustedCities].sort((a, b) => a.distance_km - b.distance_km)
|
||||
};
|
||||
addMarkersToMap(adjustedCities, prev.conurbations.length);
|
||||
return updatedData;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching more cities during flight:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const nearestCity = lineOfSightData.conurbations.find(city =>
|
||||
Math.abs(city.distance_km - currentProgress) < 100
|
||||
);
|
||||
|
||||
if (nearestCity && nearestCity.id !== lastNearestCityId) {
|
||||
lastNearestCityId = nearestCity.id;
|
||||
setSelectedCity(nearestCity);
|
||||
showCityPopup(nearestCity);
|
||||
}
|
||||
|
||||
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 map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
clearCityMarkers();
|
||||
if (map.getSource('line-of-sight')) {
|
||||
map.removeLayer('line-of-sight');
|
||||
map.removeSource('line-of-sight');
|
||||
}
|
||||
|
||||
map.addSource('line-of-sight', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: data.line_coordinates.map(coord => [coord.lon, coord.lat])
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mapRef.current.remove();
|
||||
};
|
||||
}, []);
|
||||
map.addLayer({
|
||||
id: 'line-of-sight',
|
||||
type: 'line',
|
||||
source: 'line-of-sight',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#FF6B6B',
|
||||
'line-width': 4,
|
||||
'line-opacity': 0.8
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Update preview whenever point or direction changes
|
||||
if (mapRef.current && mapRef.current.isStyleLoaded()) {
|
||||
updatePreviewLine(selectedPoint, direction);
|
||||
}
|
||||
}, [selectedPoint, direction]);
|
||||
addMarkersToMap(data.conurbations);
|
||||
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
data.line_coordinates.forEach(coord => {
|
||||
bounds.extend([coord.lon, coord.lat]);
|
||||
});
|
||||
map.fitBounds(bounds, { padding: 50 });
|
||||
};
|
||||
|
||||
const updatePreviewLine = (point, bearing) => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.getSource('preview-line')) return;
|
||||
|
||||
// Generate 50 points along a 5000km great circle path for a smooth curve
|
||||
const path = [];
|
||||
const steps = 50;
|
||||
const totalDistance = 5000;
|
||||
const totalDistance = 20000;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const dist = (totalDistance * i) / steps;
|
||||
@@ -109,9 +467,8 @@ const APP = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to calculate destination point given start, bearing, and distance (km)
|
||||
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const R = 6371;
|
||||
const brng = (bearing * Math.PI) / 180;
|
||||
const φ1 = (lat * Math.PI) / 180;
|
||||
const λ1 = (lon * Math.PI) / 180;
|
||||
@@ -135,20 +492,42 @@ const APP = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Update map style when toggle changes
|
||||
if (mapRef.current) {
|
||||
mapRef.current.setStyle(getMapStyle(mapStyle));
|
||||
}
|
||||
}, [mapStyle]);
|
||||
|
||||
const getMapStyle = (style) => {
|
||||
if (style === 'satellite') {
|
||||
return {
|
||||
version: 8,
|
||||
sources: {
|
||||
'esri-satellite': {
|
||||
type: 'raster',
|
||||
tiles: ['https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'satellite-layer',
|
||||
type: 'raster',
|
||||
source: 'esri-satellite',
|
||||
minzoom: 0,
|
||||
maxzoom: 20
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
return style === 'dark'
|
||||
? 'https://demotiles.maplibre.org/style.json'
|
||||
: 'https://demotiles.maplibre.org/style.json'; // Using same for now, can be customized
|
||||
: 'https://demotiles.maplibre.org/style.json';
|
||||
};
|
||||
|
||||
const handleShowLineOfSight = async () => {
|
||||
setLoading(true);
|
||||
setSelectedCity(null);
|
||||
try {
|
||||
const response = await apiService.getLineOfSight(
|
||||
selectedPoint.lat,
|
||||
@@ -157,8 +536,14 @@ const APP = () => {
|
||||
tolerance
|
||||
);
|
||||
|
||||
setLineOfSightData(response.data);
|
||||
renderLineOnMap(response.data);
|
||||
setLineOfSightData(response.data.data);
|
||||
setIsLocked(true);
|
||||
|
||||
if (mapRef.current && mapRef.current.getLayer('preview-line')) {
|
||||
mapRef.current.setLayoutProperty('preview-line', 'visibility', 'none');
|
||||
}
|
||||
|
||||
renderLineOnMap(response.data.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching line of sight:', error);
|
||||
} finally {
|
||||
@@ -166,81 +551,13 @@ const APP = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderLineOnMap = (data) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
// Clear existing line
|
||||
if (map.getSource('line-of-sight')) {
|
||||
map.removeLayer('line-of-sight');
|
||||
map.removeSource('line-of-sight');
|
||||
}
|
||||
|
||||
// Add line source
|
||||
map.addSource('line-of-sight', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: data.line_coordinates.map(coord => [coord.lon, coord.lat])
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add line layer
|
||||
map.addLayer({
|
||||
id: 'line-of-sight',
|
||||
type: 'line',
|
||||
source: 'line-of-sight',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#FF6B6B',
|
||||
'line-width': 4,
|
||||
'line-opacity': 0.8
|
||||
}
|
||||
});
|
||||
|
||||
// Add city markers
|
||||
data.conurbations.forEach((city, index) => {
|
||||
const markerId = `city-${index}`;
|
||||
|
||||
// Create marker element
|
||||
const el = document.createElement('div');
|
||||
el.className = 'city-marker';
|
||||
el.innerHTML = `
|
||||
<div class="marker-dot"></div>
|
||||
<div class="marker-label">${city.name}</div>
|
||||
<div class="marker-pop">${(city.population / 1000000).toFixed(1)}M</div>
|
||||
`;
|
||||
|
||||
// Add marker to map
|
||||
new maplibregl.Marker(el)
|
||||
.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);
|
||||
});
|
||||
|
||||
// Fit bounds to show line
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
data.line_coordinates.forEach(coord => {
|
||||
bounds.extend([coord.lon, coord.lat]);
|
||||
});
|
||||
map.fitBounds(bounds, { padding: 50 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<div className="map-container" ref={mapContainerRef}></div>
|
||||
<div className="map-container" ref={mapContainerRef}>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<div className="control-group">
|
||||
<div className={`control-group ${isLocked ? 'disabled-controls' : ''}`}>
|
||||
<h3>Line of Sight Settings</h3>
|
||||
|
||||
<div className="setting-row">
|
||||
@@ -255,6 +572,7 @@ const APP = () => {
|
||||
min="0"
|
||||
max="360"
|
||||
value={direction}
|
||||
disabled={isLocked}
|
||||
onChange={(e) => setDirection(parseInt(e.target.value))}
|
||||
/>
|
||||
<span>{direction}°</span>
|
||||
@@ -265,6 +583,7 @@ const APP = () => {
|
||||
<input
|
||||
type="number"
|
||||
value={tolerance}
|
||||
disabled={isLocked}
|
||||
onChange={(e) => setTolerance(parseInt(e.target.value))}
|
||||
min="10"
|
||||
max="200"
|
||||
@@ -274,45 +593,79 @@ const APP = () => {
|
||||
|
||||
<div className="setting-row">
|
||||
<label>Map Style:</label>
|
||||
<button onClick={() => setMapStyle('light')}>Light</button>
|
||||
<button onClick={() => setMapStyle('dark')}>Dark</button>
|
||||
<button
|
||||
className={mapStyle === 'light' ? 'active-style' : ''}
|
||||
onClick={() => setMapStyle('light')}
|
||||
>Light</button>
|
||||
<button
|
||||
className={mapStyle === 'dark' ? 'active-style' : ''}
|
||||
onClick={() => setMapStyle('dark')}
|
||||
>Dark</button>
|
||||
<button
|
||||
className={mapStyle === 'satellite' ? 'active-style' : ''}
|
||||
onClick={() => setMapStyle('satellite')}
|
||||
>Satellite</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={handleShowLineOfSight}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
|
||||
</button>
|
||||
{!isLocked ? (
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={handleShowLineOfSight}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={startFlyOver}
|
||||
style={{ backgroundColor: isPlaying ? '#e74c3c' : '#3498db' }}
|
||||
>
|
||||
{isPlaying ? `⏹ Stop Flight (${flightSpeed.toFixed(1)}x)` : '✈️ Fly Over Route'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn-secondary"
|
||||
onClick={handleStartAgain}
|
||||
>
|
||||
🔄 Start Again
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lineOfSightData && (
|
||||
<div className="results-panel">
|
||||
<h3>Conurbations Found ({lineOfSightData.conurbations.length})</h3>
|
||||
<p className="hint">Click a city for details</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>City</th>
|
||||
<th>Population</th>
|
||||
<th>Distance</th>
|
||||
<th>Dist.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineOfSightData.conurbations.slice(0, 10).map((city, index) => (
|
||||
<tr key={city.id}>
|
||||
{lineOfSightData.conurbations.map((city, index) => (
|
||||
<tr
|
||||
key={city.id}
|
||||
onClick={() => {
|
||||
setSelectedCity(city);
|
||||
showCityPopup(city);
|
||||
mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 });
|
||||
}}
|
||||
className={selectedCity?.id === city.id ? 'selected-row' : ''}
|
||||
>
|
||||
<td>{index + 1}</td>
|
||||
<td>{city.name}</td>
|
||||
<td>{(city.population / 1000000).toFixed(1)}M</td>
|
||||
<td>{city.distance_km} km</td>
|
||||
<td>{(city.population / 1000).toFixed(0)}k</td>
|
||||
<td>{city.distance_km}km</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{lineOfSightData.conurbations.length > 10 && (
|
||||
<p className="more-info">... and {lineOfSightData.conurbations.length - 10} more cities</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+215
-5
@@ -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 {
|
||||
@@ -78,6 +81,12 @@
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.setting-row button.active-style {
|
||||
background: #34495e;
|
||||
color: white;
|
||||
border-color: #2c3e50;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
@@ -101,18 +110,66 @@
|
||||
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 {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Allow action buttons and settings buttons to work even when locked */
|
||||
.action-btn, .action-btn-secondary, .setting-row button {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.results-panel table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.results-panel {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.results-panel h3 {
|
||||
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 +181,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 +327,25 @@
|
||||
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 {
|
||||
@@ -186,6 +355,7 @@
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.marker-label {
|
||||
@@ -209,6 +379,38 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-info-popup {
|
||||
padding: 5px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 5px;
|
||||
color: #2c3e50;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.popup-body p {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.maplibregl-popup {
|
||||
z-index: 2000 !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-content {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3) !important;
|
||||
padding: 15px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
flex-direction: column;
|
||||
@@ -223,4 +425,12 @@
|
||||
.map-container {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.info-panel-modal {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
width: auto;
|
||||
bottom: 20px;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user