const express = require('express'); const cors = require('cors'); const { Pool } = require('pg'); require('dotenv').config(); const app = express(); const PORT = process.env.PORT || 3051; 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()); 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; 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 }; }; const haversineKm = (lat1, lon1, lat2, lon2) => { const R = 6371; const toRad = (d) => d * Math.PI / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }; app.get('/api/line-of-sight', async (req, res) => { const { lat, lon, direction, tolerance } = req.query; 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 request: lat=${startLat}, lon=${startLon}, bearing=${bearing}, tolerance=${toleranceKm}`); try { const pathPoints = []; const totalDistance = 40074; const steps = 160; for (let i = 0; i <= steps; i++) { const dist = (totalDistance * i) / steps; pathPoints.push(calculateDestination(startLat, startLon, bearing, dist)); } // Batch water check: a point is "over water" if no city is within 500 km 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 r = await pool.query(checkQuery, [p.lon, p.lat]); return !r.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(',')})`; // Top 5 cities per 100 km bin, ranked by population descending const query = ` WITH path AS ( SELECT ST_GeogFromText($1) as route, ST_MakePoint($3, $4)::geography as start_node ), candidates AS ( 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)::geography::geometry, geom::geometry) as pos_on_line, FLOOR(ST_LineLocatePoint((SELECT route FROM path)::geography::geometry, geom::geometry) * 200)::int as bin_200km FROM cities WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000) ), ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY bin_200km ORDER BY population DESC NULLS LAST) as rank_in_bin FROM candidates ) SELECT id, name, population, country, lat, lon, distance_off_line_km, distance_from_start_km, pos_on_line FROM ranked WHERE rank_in_bin <= 10 ORDER BY pos_on_line ASC; `; const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]); // Greedy dynamic deduplication: sort by population desc, accept a city only if // it satisfies a distance threshold that depends on its importance (population). const byPopulation = [...result.rows].sort((a, b) => (b.population || 0) - (a.population || 0)); const accepted = []; for (const city of byPopulation) { const cityPop = city.population || 0; const tooClose = accepted.some(s => { const dist = haversineKm(s.lat, s.lon, city.lat, city.lon); const sPop = s.population || 0; // Major hubs (>1M) can be closer (30km) to each other (e.g., Tokyo/Yokohama) if (cityPop > 1000000 && sPop > 1000000) return dist < 30; // Large cities (>100k) need at least 50km space if (cityPop > 100000 || sPop > 100000) return dist < 50; // Smaller towns and villages need 80km space from any other accepted place // to prevent clutter in high-density regions (like Europe/East Coast). // Isolated towns (Alaska/Outback) will still show up as there's nothing else near them. return dist < 80; }); if (!tooClose) accepted.push(city); } accepted.sort((a, b) => a.pos_on_line - b.pos_on_line); res.json({ success: true, data: { start_point: { lat: startLat, lon: startLon }, direction: bearing, tolerance_km: toleranceKm, conurbations: accepted.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' }); } }); app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); if (require.main === module) { app.listen(PORT, '0.0.0.0', () => { console.log(`Line of Sight Backend running on port ${PORT}`); }); } module.exports = { app, calculateDestination };