17 Commits

Author SHA1 Message Date
(jenkins) c26f458df7 Fix issue with localhost refs
Tests / backend-test (pull_request) Successful in 6s
Tests / frontend-test (pull_request) Failing after 7s
Tests / e2e-test (pull_request) Failing after 1m29s
2026-04-17 00:45:24 +01:00
(jenkins) 8d646ecd74 Fix issue with db error
Tests / backend-test (pull_request) Successful in 6s
Tests / frontend-test (pull_request) Failing after 9s
Tests / e2e-test (pull_request) Failing after 1m30s
2026-04-17 00:41:41 +01:00
(jenkins) 8890e64d0e Optimise db query. Add more error detail.
Tests / backend-test (pull_request) Successful in 6s
Tests / frontend-test (pull_request) Failing after 7s
Tests / e2e-test (pull_request) Failing after 1m31s
2026-04-17 00:34:55 +01:00
(jenkins) 411d10bbc6 Add debugging for errors.
Tests / backend-test (pull_request) Successful in 6s
Tests / frontend-test (pull_request) Failing after 9s
Tests / e2e-test (pull_request) Failing after 1m29s
2026-04-17 00:31:25 +01:00
(jenkins) 76eed69c8f Play/pause flyover
Tests / backend-test (pull_request) Successful in 6s
Tests / frontend-test (pull_request) Failing after 8s
Tests / e2e-test (pull_request) Failing after 1m29s
2026-04-17 00:26:05 +01:00
(jenkins) 0bba5b1abe better projections
Tests / backend-test (pull_request) Successful in 6s
Tests / frontend-test (pull_request) Failing after 8s
Tests / e2e-test (pull_request) Failing after 1m29s
2026-04-17 00:20:56 +01:00
(jenkins) 6a2f8856fe Simplify popups
Tests / backend-test (pull_request) Successful in 6s
Tests / frontend-test (pull_request) Failing after 7s
Tests / e2e-test (pull_request) Failing after 1m30s
2026-04-17 00:13:29 +01:00
(jenkins) 394394b387 Add data initi script
Tests / backend-test (pull_request) Successful in 6s
Tests / frontend-test (pull_request) Failing after 8s
Tests / e2e-test (pull_request) Failing after 1m30s
2026-04-17 00:09:00 +01:00
(jenkins) eda4697b03 Route api traffic through the main container.
Tests / backend-test (pull_request) Successful in 7s
Tests / frontend-test (pull_request) Failing after 8s
Tests / e2e-test (pull_request) Failing after 1m29s
2026-04-16 23:59:14 +01:00
(jenkins) 29048e9d2a Switch to "true" for allowedHosts
Tests / backend-test (pull_request) Successful in 10s
Tests / frontend-test (pull_request) Failing after 10s
Tests / e2e-test (pull_request) Failing after 1m36s
2026-04-16 23:52:15 +01:00
(jenkins) 0826c606ba allow any host name for now.
Tests / backend-test (pull_request) Successful in 10s
Tests / frontend-test (pull_request) Failing after 12s
Tests / e2e-test (pull_request) Failing after 1m38s
2026-04-16 23:50:26 +01:00
(jenkins) e5413b4f0c remove port mapping for postgres
Tests / backend-test (pull_request) Successful in 11s
Tests / frontend-test (pull_request) Failing after 16s
Tests / e2e-test (pull_request) Failing after 1m36s
2026-04-16 23:40:16 +01:00
(jenkins) b1dc09c714 don't fix to arm64 architecure!
Tests / backend-test (pull_request) Successful in 6s
Tests / frontend-test (pull_request) Failing after 8s
Tests / e2e-test (pull_request) Failing after 1m29s
2026-04-16 23:32:11 +01:00
(jenkins) 62959398d5 Add better handling of places along line.
Tests / backend-test (pull_request) Successful in 9s
Tests / frontend-test (pull_request) Failing after 8s
Tests / e2e-test (pull_request) Failing after 1m30s
2026-04-16 23:22:42 +01:00
(jenkins) b98bac9cff feat: add geolocation button to map to center view on user position 2026-04-16 22:57:59 +01:00
(jenkins) c83049aca5 Update application ports to 3051 and enhance frontend/backend configurations 2026-04-08 16:15:58 +01:00
(jenkins) 234ed8ae94 Initial commit for new projection support. 2026-04-01 12:06:32 +01:00
16 changed files with 897 additions and 398 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
"allow": [
"Bash(npm test:*)",
"Bash(npm run:*)",
"Bash(npx playwright:*)"
"Bash(npx playwright:*)",
"Bash(node -e ':*)"
]
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
### Docker (preferred workflow)
```bash
docker-compose up --build # Start all services (frontend :3050, backend :3001, postgres)
docker-compose up --build # Start all services (frontend :3050, backend :3051, postgres)
docker-compose down # Stop all services
docker-compose logs -f # View logs
@@ -25,7 +25,7 @@ docker-compose exec frontend npm run test:e2e
```bash
# Backend
cd backend && npm run dev # Nodemon dev server on :3001
cd backend && npm run dev # Nodemon dev server on :3051
# Frontend
cd frontend && npm run start # Vite dev server on :3050
+11
View File
@@ -68,6 +68,17 @@
---
## 📊 Data Management
The project includes a compressed dataset of ~68,000 cities from Natural Earth.
| Action | Command |
| :--- | :--- |
| **Import/Refresh Data** | `./import-cities.sh` |
*Note: The script will truncate the `cities` table and perform a fresh import of the global dataset stored in `docker/data/cities.csv.gz`.*
---
## 📝 Roadmap Highlights
- [ ] Implement real ST_DWithin() PostGIS queries in the backend.
- [ ] Import full Natural Earth/GeoNames datasets into the `cities` table.
+2 -2
View File
@@ -52,7 +52,7 @@ docker-compose up --build
### 3. Access Application
- **Frontend**: http://localhost:3050
- **Backend API**: http://localhost:3001
- **Backend API**: http://localhost:3051
- **Database**: localhost:5432 (PostgreSQL with PostGIS)
## 🎮 How to Use
@@ -220,7 +220,7 @@ npm run dev
```bash
# Find process using port
lsof -i :3050
lsof -i :3001
lsof -i :3051
# Kill process
kill -9 <PID>
+1 -1
View File
@@ -10,7 +10,7 @@ RUN npm install
COPY . .
# Expose port
EXPOSE 3001
EXPOSE 3051
# Start server
CMD ["npm", "start"]
+82 -37
View File
@@ -4,9 +4,8 @@ const { Pool } = require('pg');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3001;
const PORT = process.env.PORT || 3051;
// 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'
});
@@ -14,7 +13,6 @@ const pool = new Pool({
app.use(cors());
app.use(express.json());
// 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;
@@ -39,7 +37,15 @@ const calculateDestination = (lat, lon, bearing, distance) => {
};
};
// Real API endpoint - uses PostGIS for spatial queries
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;
@@ -48,32 +54,41 @@ app.get('/api/line-of-sight', async (req, res) => {
const bearing = parseInt(direction) || 0;
const toleranceKm = parseInt(tolerance) || 50;
console.log(`Processing real request: lat=${startLat}, lon=${startLon}, bearing=${bearing}, tolerance=${toleranceKm}`);
console.log(`Processing 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
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 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;
}));
// Bulk land check: use a single query for all points to avoid connection pool exhaustion
const waterStart = Date.now();
const lons = pathPoints.map(p => p.lon);
const lats = pathPoints.map(p => p.lat);
const waterCheckQuery = `
WITH points AS (
SELECT i, ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography as p_geom
FROM unnest($1::float8[], $2::float8[]) WITH ORDINALITY AS t(lon, lat, 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 waterResults = await pool.query(waterCheckQuery, [lons, lats]);
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,
@@ -82,28 +97,58 @@ app.get('/api/line-of-sight', async (req, res) => {
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,
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;
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 startTime = Date.now();
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
const queryTime = Date.now() - startTime;
console.log(`Query completed in ${queryTime}ms. Found ${result.rows.length} candidates.`);
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) {
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);
console.log(`Deduplication completed in ${Date.now() - startDedupe}ms. Final count: ${accepted.length}`);
res.json({
success: true,
@@ -111,7 +156,7 @@ app.get('/api/line-of-sight', async (req, res) => {
start_point: { lat: startLat, lon: startLon },
direction: bearing,
tolerance_km: toleranceKm,
conurbations: result.rows.map(row => ({
conurbations: accepted.map(row => ({
...row,
name: row.name || 'Unknown',
country: row.country || 'Unknown',
@@ -121,13 +166,13 @@ app.get('/api/line-of-sight', async (req, res) => {
line_coordinates: pathPointsWithWater
}
});
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' });
}
});
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
+5 -10
View File
@@ -1,17 +1,11 @@
version: '3.8'
services:
postgres:
image: kartoza/postgis:latest
platform: linux/arm64
container_name: line-of-sight-db
environment:
- POSTGRES_DB=line_of_sight
- POSTGRES_USER=line_of_sight
- POSTGRES_PASS=line_of_sight_pass
- ALLOW_IP_RANGE=0.0.0.0/0
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
@@ -26,11 +20,12 @@ services:
context: ./backend
dockerfile: Dockerfile
container_name: line-of-sight-backend
ports:
- "3001:3001"
# Removed public port exposure for security; accessible via frontend proxy
# ports:
# - "3051:3051"
environment:
- DATABASE_URL=postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight
- PORT=3001
- PORT=3051
depends_on:
postgres:
condition: service_healthy
@@ -46,7 +41,7 @@ services:
ports:
- "3050:3050"
environment:
- VITE_API_URL=http://localhost:3001/api
- VITE_API_URL=/api
depends_on:
- backend
volumes:
Binary file not shown.
+2 -2
View File
@@ -12,8 +12,8 @@ COPY . .
# Expose port
EXPOSE 3050
# Set environment variable for API URL
ENV VITE_API_URL=http://localhost:3001/api
# Set environment variable for API URL (relative to the frontend host)
ENV VITE_API_URL=/api
# Start development server
CMD ["npm", "start"]
+3
View File
@@ -33,6 +33,9 @@ class Map {
flyTo() {}
jumpTo() {}
setStyle() { Promise.resolve().then(() => this._emit('style.load')); }
setProjection() {}
hasImage() { return false; }
addImage() {}
getCanvas() { return document.createElement('canvas'); }
project() { return { x: 0, y: 0 }; }
unproject() { return { lng: 0, lat: 0 }; }
+516 -246
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -23,6 +23,9 @@ vi.mock('maplibre-gl', () => {
isStyleLoaded: vi.fn(() => true),
setSky: vi.fn(),
setTerrain: vi.fn(),
setProjection: vi.fn(),
hasImage: vi.fn(() => false),
addImage: vi.fn(),
};
return {
+2 -5
View File
@@ -1,13 +1,10 @@
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
timeout: 30000
});
export const getLineOfSight = async (lat, lon, direction, tolerance) => {
+158 -57
View File
@@ -13,6 +13,30 @@
z-index: 1;
}
.locate-btn {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
width: 36px;
height: 36px;
background: white;
border: none;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #333;
transition: background 0.15s, color 0.15s;
}
.locate-btn:hover {
background: #f0f0f0;
color: #1a73e8;
}
.controls {
width: 350px;
background: white;
@@ -176,6 +200,7 @@
width: 100%;
border-collapse: collapse;
font-size: 13px;
table-layout: fixed;
}
.results-panel th {
@@ -189,6 +214,13 @@
padding: 8px 4px;
border-bottom: 1px solid #ddd;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-panel tr {
will-change: transform, background-color;
}
.results-panel tr:nth-child(even) {
@@ -322,63 +354,6 @@
margin-bottom: 8px;
}
.city-marker {
display: flex;
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 {
width: 12px;
height: 12px;
background: #e74c3c;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
z-index: 1;
}
.marker-label {
background: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
margin-top: 2px;
white-space: nowrap;
}
.marker-pop {
background: #34495e;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
margin-top: 2px;
white-space: nowrap;
}
.map-info-popup {
padding: 5px;
font-family: sans-serif;
@@ -411,6 +386,37 @@
padding: 15px !important;
}
.projection-row {
align-items: flex-start;
}
.projection-buttons {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 2;
}
.projection-buttons button {
padding: 4px 8px;
font-size: 11px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.projection-buttons button:hover {
background: #e9ecef;
}
.projection-buttons button.active-style {
background: #34495e;
color: white;
border-color: #2c3e50;
}
@media (max-width: 768px) {
.app-container {
flex-direction: column;
@@ -434,3 +440,98 @@
top: auto;
}
}
.results-panel tr.passed-row {
opacity: 0.4;
}
.results-panel tr.current-row {
background: #1abc9c !important;
color: white;
font-weight: bold;
}
.results-panel tr.upcoming-row {
background: #eaf4fb !important;
}
.now-passing {
background: #1abc9c;
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
margin-bottom: 8px;
}
.error-banner {
background: #fdecea;
color: #d32f2f;
padding: 10px 15px;
border-radius: 6px;
border-left: 4px solid #d32f2f;
font-size: 13px;
margin: 10px 0;
display: flex;
align-items: center;
gap: 8px;
}
.fly-controls {
display: flex;
gap: 8px;
margin-top: 10px;
}
.fly-button {
flex: 3;
padding: 12px;
border: none;
border-radius: 6px;
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.fly-button.play {
background: #3498db;
}
.fly-button.play:hover {
background: #2980b9;
}
.fly-button.pause {
background: #e74c3c;
}
.fly-button.pause:hover {
background: #c0392b;
}
.reset-flight-btn {
flex: 1;
padding: 12px;
background: #95a5a6;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.reset-flight-btn:hover:not(:disabled) {
background: #7f8c8d;
}
.reset-flight-btn:disabled {
background: #ecf0f1;
color: #bdc3c7;
cursor: not-allowed;
}
+7
View File
@@ -12,6 +12,13 @@ export default defineConfig({
server: {
port: 3050,
host: '0.0.0.0',
allowedHosts: true,
proxy: {
'/api': {
target: 'http://backend:3051',
changeOrigin: true
}
}
},
build: {
outDir: 'build',
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# Configuration
CONTAINER_NAME="line-of-sight-db"
DB_USER="line_of_sight"
DB_NAME="line_of_sight"
DATA_FILE="./docker/data/cities.csv.gz"
# Check if docker is available using the known path
DOCKER_BIN="/usr/local/bin/docker"
if [ ! -f "$DOCKER_BIN" ]; then
DOCKER_BIN=$(which docker)
fi
if [ -z "$DOCKER_BIN" ]; then
echo "Error: docker command not found."
exit 1
fi
# Check if data file exists
if [ ! -f "$DATA_FILE" ]; then
echo "Error: Data file $DATA_FILE not found."
exit 1
fi
# Check if container is running
if ! "$DOCKER_BIN" ps | grep -q "$CONTAINER_NAME"; then
echo "Error: Container $CONTAINER_NAME is not running."
echo "Please run 'docker-compose up -d' first."
exit 1
fi
echo "🚀 Starting database initialization..."
# Use a HEREDOC to execute multi-line SQL
# We load into a temp table first to handle the WKT -> Geography conversion
IMPORT_SQL=$(cat <<EOF
CREATE TEMP TABLE tmp_cities (
name VARCHAR(255),
population INTEGER,
country VARCHAR(100),
geom_wkt TEXT
);
COPY tmp_cities FROM STDIN WITH CSV HEADER;
TRUNCATE cities;
INSERT INTO cities (name, population, country, geom)
SELECT
name,
population,
country,
ST_GeomFromText(geom_wkt, 4326)::geography
FROM tmp_cities;
DROP TABLE tmp_cities;
SELECT count(*) || ' cities successfully imported.' as result FROM cities;
EOF
)
# Stream the gzipped data into psql
gunzip -c "$DATA_FILE" | "$DOCKER_BIN" exec -i -e PGPASSWORD=line_of_sight_pass "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -h 127.0.0.1 -c "$IMPORT_SQL"
echo "✅ Initialization complete."