Files
line-of-sight/backend/app/server.js
T

185 lines
6.3 KiB
JavaScript
Raw Normal View History

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;
2026-04-01 12:06:32 +01:00
const steps = 160;
for (let i = 0; i <= steps; i++) {
const dist = (totalDistance * i) / steps;
pathPoints.push(calculateDestination(startLat, startLon, bearing, dist));
}
2026-04-17 00:34:55 +01:00
// Bulk land check: use a single query for all points to avoid connection pool exhaustion
const waterStart = Date.now();
const waterCheckQuery = `
WITH points AS (
SELECT i, ST_SetSRID(ST_MakePoint(val[1], val[2]), 4326)::geography as p_geom
FROM unnest($1::float8[][]) WITH ORDINALITY AS t(val, i)
)
SELECT i, EXISTS (
SELECT 1 FROM cities
WHERE ST_DWithin(geom, p_geom, 500000)
LIMIT 1
) as has_land
FROM points
ORDER BY i;
`;
const pointsArray = pathPoints.map(p => [p.lon, p.lat]);
const waterResults = await pool.query(waterCheckQuery, [pointsArray]);
const waterChecks = waterResults.rows.map(r => !r.has_land);
console.log(`Bulk water check completed in ${Date.now() - waterStart}ms for ${pathPoints.length} points.`);
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,
2026-04-16 23:22:42 +01:00
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 *,
2026-04-16 23:22:42 +01:00
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
2026-04-16 23:22:42 +01:00
WHERE rank_in_bin <= 10
ORDER BY pos_on_line ASC;
`;
2026-04-17 00:34:55 +01:00
const startTime = Date.now();
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
2026-04-17 00:34:55 +01:00
const queryTime = Date.now() - startTime;
console.log(`Query completed in ${queryTime}ms. Found ${result.rows.length} candidates.`);
2026-04-17 00:34:55 +01:00
const startDedupe = Date.now();
// Greedy dynamic deduplication...
const byPopulation = [...result.rows].sort((a, b) => (b.population || 0) - (a.population || 0));
const accepted = [];
for (const city of byPopulation) {
2026-04-16 23:22:42 +01:00
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;
if (cityPop > 1000000 && sPop > 1000000) return dist < 30;
if (cityPop > 100000 || sPop > 100000) return dist < 50;
return dist < 80;
});
if (!tooClose) accepted.push(city);
}
accepted.sort((a, b) => a.pos_on_line - b.pos_on_line);
2026-04-17 00:34:55 +01:00
console.log(`Deduplication completed in ${Date.now() - startDedupe}ms. Final count: ${accepted.length}`);
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
}
});
2026-04-17 00:34:55 +01:00
console.log(`Request fully processed in ${Date.now() - startTime}ms`);
} 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 };