34 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
steve-admin bd5f5b98cc Merge pull request 'Configure Playwright for headless CI and add Gitea workflow with container support' (#5) from add-automated-testing into main
Tests / backend-test (push) Successful in 7s
Tests / frontend-test (push) Successful in 8s
Tests / e2e-test (push) Successful in 10s
Reviewed-on: #5
2026-04-01 11:19:26 +01:00
(jenkins) 205e2d5be5 Update playwright version
Tests / backend-test (pull_request) Successful in 7s
Tests / frontend-test (pull_request) Successful in 7s
Tests / e2e-test (pull_request) Successful in 51s
2026-04-01 11:18:05 +01:00
(jenkins) 86aefba065 Disable cache on gitea tests
Tests / backend-test (pull_request) Successful in 7s
Tests / frontend-test (pull_request) Successful in 8s
Tests / e2e-test (pull_request) Failing after 13s
2026-04-01 11:16:03 +01:00
(jenkins) 228032c913 Update application ports to 3050 and enhance Playwright tests with WebGL mock
Tests / backend-test (pull_request) Successful in 10s
Tests / frontend-test (pull_request) Successful in 9m25s
Tests / e2e-test (pull_request) Has been cancelled
2026-04-01 11:04:16 +01:00
(jenkins) cac76a2f69 Disable npm cache for now.
Tests / backend-test (pull_request) Successful in 5s
Tests / frontend-test (pull_request) Successful in 9m25s
Tests / e2e-test (pull_request) Failing after 5m28s
2026-03-17 00:34:17 +00:00
(jenkins) bd9e92f304 Configure Playwright for headless CI and add Gitea workflow with container support
Tests / frontend-test (pull_request) Has been cancelled
Tests / e2e-test (pull_request) Has been cancelled
Tests / backend-test (pull_request) Has been cancelled
2026-03-17 00:29:39 +00:00
steve-admin 4326109722 Merge pull request 'feature/modernize-and-enhance' (#4) from feature/modernize-and-enhance into main
Reviewed-on: #4
2026-03-17 00:13:42 +00:00
(jenkins) 701904dca7 Merge branch 'main' into feature/modernize-and-enhance 2026-03-17 00:13:28 +00:00
(jenkins) 25f3a09374 Implement 20x warp speed over water with land prediction and speedometer UI 2026-03-17 00:08:50 +00:00
(jenkins) f89bda232e Add missing turf import for flyover animation 2026-03-16 23:36:06 +00:00
(jenkins) 259fdf1e10 Fix flyover button clickability and camera positioning 2026-03-16 23:35:22 +00:00
(jenkins) 8be3e04803 Implement 3D globe fly-over animation using MapLibre FreeCamera and Turf.js 2026-03-16 23:19:40 +00:00
(jenkins) f0767c798c Extend line range to antipode (20k km), increase city limit to 100, and fix info panel layout 2026-03-16 20:50:35 +00:00
(jenkins) ad07a70125 Implement real GIS data import and PostGIS spatial queries 2026-03-16 20:32:22 +00:00
(jenkins) e764efb189 Fix TypeError in results panel and properly manage city markers 2026-03-16 20:28:04 +00:00
steve-admin af578aaff2 Merge pull request 'Modernize stack (React 19, Vite, Node 24) and add map preview features' (#2) from feature/modernize-and-enhance into main
Reviewed-on: #2
2026-03-16 20:18:51 +00:00
(jenkins) abae851315 Modernize stack (React 19, Vite, Node 24) and add map preview features 2026-03-16 19:46:08 +00:00
33 changed files with 13609 additions and 458 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(npm run:*)",
"Bash(npx playwright:*)",
"Bash(node -e ':*)"
]
}
}
+55
View File
@@ -0,0 +1,55 @@
name: Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
backend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
# cache: ''npm''
# cache-dependency-path: backend/package-lock.json
- name: Install dependencies
run: cd backend && npm ci
- name: Run tests
run: cd backend && npm test
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
# cache: 'npm'
# cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: cd frontend && npm ci
- name: Run unit tests
run: cd frontend && npm test -- --run
e2e-test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
# cache: 'npm'
# cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: cd frontend && npm install
- name: Run E2E tests
run: cd frontend && npm run test:e2e
+70
View File
@@ -0,0 +1,70 @@
# Copilot Instructions for line-of-sight
## Purpose
This repository is a full-stack geospatial app:
- `frontend/`: React + Vite + MapLibre UI
- `backend/`: Express API with PostGIS queries
- `docker/` and `docker-compose.yml`: local orchestration and DB initialization
Prefer small, focused changes that keep frontend/backend contracts stable.
## First Commands to Know
Use these commands first when validating changes.
### Docker-first workflow (preferred)
- Start stack: `docker-compose up --build`
- Stop stack: `docker-compose down`
- Backend tests: `docker-compose exec backend npm test`
- Frontend unit tests: `docker-compose exec frontend npm test -- --run`
- Frontend E2E tests: `docker-compose exec frontend npm run test:e2e`
- Seed geodata: `docker-compose exec backend npm run seed-data`
### Local workflow (without Docker)
- Root Node version: `nvm use` (uses `.nvmrc`, currently Node 24)
- Backend dev: `cd backend && npm install && npm run dev`
- Frontend dev: `cd frontend && npm install && npm run start`
## Architecture and Boundaries
- Backend API entrypoint: `backend/app/server.js`
- Defines `GET /api/line-of-sight` and `GET /api/health`
- Performs destination/path generation and PostGIS querying
- Frontend app entrypoint: `frontend/src/App.jsx`
- Owns map lifecycle, animation, and UI state
- Calls backend via `frontend/src/services/api.js`
- Database bootstrap: `docker/init.sql`
- Enables PostGIS and creates `cities` with spatial index
When changing behavior, prefer backend geospatial logic over duplicating calculations in the frontend.
## Project Conventions
- Keep geospatial coordinates in WGS84 (`SRID 4326`) and preserve existing lat/lon parameter naming.
- Keep API responses JSON-compatible and backward compatible unless explicitly requested.
- Keep frontend styles in `frontend/src/styles/` (project uses custom CSS, not utility CSS frameworks).
- Avoid broad refactors in `App.jsx` unless task requires it; map lifecycle and refs are tightly coupled.
## Testing Expectations
- Backend tests live in `backend/tests/` and use Jest + Supertest.
- Frontend unit tests live in `frontend/src/__tests__/` and use Vitest + Testing Library.
- E2E tests live in `frontend/e2e/` and use Playwright.
- For feature changes:
- Run the closest unit tests first.
- Run E2E tests when user flows or map interactions are changed.
## Common Pitfalls
- PostGIS must be available before backend geospatial endpoints are exercised.
- `docker-compose.yml` pins Postgres service to `linux/arm64`; this can affect non-ARM hosts.
- Data import script (`backend/scripts/import_cities.js`) depends on `wget` and `unzip` availability.
- `README.md` contains roadmap/history notes; trust current scripts/config in source files first.
## CI Notes
CI config is in `.gitea/workflows/test.yml`.
- Backend and frontend tests run on Node 24.
- Frontend unit tests run with `npm test -- --run`.
- E2E runs in Playwright container.
## Link, Do Not Duplicate
For detailed setup and product context, reference:
- `README.md`
- `GEMINI.md`
Keep this file focused on actionable agent guidance and repo-specific guardrails.
+1
View File
@@ -0,0 +1 @@
24
+112
View File
@@ -0,0 +1,112 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Project Is
**Line of Sight** is a geospatial web app that draws a great-circle line from a user-selected point on Earth in a chosen direction, then finds all cities along that path. It includes a flight-over animation that speeds up 20x over water and slows to normal speed over land.
## Commands
### Docker (preferred workflow)
```bash
docker-compose up --build # Start all services (frontend :3050, backend :3051, postgres)
docker-compose down # Stop all services
docker-compose logs -f # View logs
# Run tests inside containers
docker-compose exec backend npm test
docker-compose exec frontend npm test -- --run
docker-compose exec frontend npm run test:e2e
```
### Local development (without Docker)
```bash
# Backend
cd backend && npm run dev # Nodemon dev server on :3051
# Frontend
cd frontend && npm run start # Vite dev server on :3050
```
### Tests
```bash
# Backend unit tests (Jest + Supertest)
cd backend && npm test
# Frontend unit tests (Vitest)
cd frontend && npm test # Watch mode
cd frontend && npm test -- --run # Single run (CI mode)
# E2E tests (Playwright)
cd frontend && npm run test:e2e
# Seed the database with GeoNames city data
cd backend && npm run seed-data
```
### Build
```bash
cd frontend && npm run build # Vite production build → frontend/build/
```
## Architecture
This is a monorepo with three services orchestrated by Docker Compose:
```
frontend/ → React 19 + Vite 6 SPA (MapLibre GL, Turf.js, Axios)
backend/ → Node 24 + Express 5 REST API
docker/ → PostgreSQL + PostGIS initialization
```
### Data flow
1. User clicks map → sets start point (lat/lon)
2. User selects direction (0360°) and tolerance (km radius)
3. Frontend calls `GET /api/line-of-sight?lat=&lon=&direction=&tolerance=`
4. Backend generates 80 path points along a great-circle arc (up to 20,000 km)
5. For each point, a PostGIS `ST_DWithin()` query finds cities within tolerance
6. Backend also checks whether each point is over land (500 km radius land check)
7. Response includes line coordinates with per-point water flags and matching cities
8. Frontend renders the path on a 3D globe map and shows city markers
### Backend: `backend/app/server.js`
Single-file Express app. Key internals:
- `calculateDestination()` — Haversine formula for great-circle destination points
- `GET /api/line-of-sight` — main endpoint: path generation + PostGIS city lookup
- `GET /api/health` — health check
- Returns up to 200 cities ordered by position along the line
### Frontend: `frontend/src/App.jsx`
Single large React component (~687 lines) managing all state and map logic:
- MapLibre GL map with 3D globe projection and terrain/sky effects
- Direction slider drives a live preview line before the user commits
- After "Show Line of Sight", map locks (prevents moving start point)
- Flight animation uses Turf.js to interpolate positions along the path; speed is 1× over land, 20× over water with smooth acceleration (0.005 step), predictive look-ahead of 2000 km
- New cities are fetched every 2000 km during flight via the same API
- Three map styles: light, dark, satellite (Esri tiles)
### Database
PostGIS `cities` table with a GIST index on `geom GEOGRAPHY(POINT, 4326)`. All coordinates use WGS84 / SRID 4326. The init SQL seeds 10 major cities; the `seed-data` script imports from GeoNames for a fuller dataset.
## Conventions
- **Geospatial**: WGS84 / SRID 4326 everywhere; PostGIS `GEOGRAPHY` type for distance queries
- **API**: RESTful; query params for `GET /api/line-of-sight`; env var `VITE_API_URL` (frontend) or `DATABASE_URL` (backend)
- **Styling**: Plain CSS files in `frontend/src/styles/` — no CSS framework
- **Tests**: Backend mocks the `pg` Pool; frontend mocks MapLibre GL and the API service. E2E tests use Playwright with a mock API response
## CI
Gitea workflow (`.gitea/workflows/test.yml`) runs on push/PR to `main`:
1. **backend-test**`npm ci && npm test`
2. **frontend-test**`npm ci && npm test -- --run`
3. **e2e-test** — uses `mcr.microsoft.com/playwright:v1.50.1` container
+86
View File
@@ -0,0 +1,86 @@
# GEMINI.md - Project Context: Line of Sight 🌍
## 🚀 Project Overview
**Line of Sight** is an interactive web application that visualizes great-circle paths around the Earth and identifies major conurbations (cities) along those paths based on user-defined direction and "fuzziness" tolerance.
### Architecture
- **Frontend**: React 19 single-page application using Vite 6 for build orchestration and MapLibre GL JS for vector map rendering.
- **Backend**: Node.js 24/Express 5 REST API.
- **Database**: PostgreSQL with the PostGIS extension for geospatial data storage and analysis. Uses `kartoza/postgis` for robust multi-arch (including ARM64) support.
- **Infrastructure**: Fully containerized using Docker and orchestrated with Docker Compose.
---
## 🛠️ Tech Stack
- **Frontend**: `React 19`, `Vite 6`, `MapLibre GL JS`, `Axios`
- **Backend**: `Node.js 24`, `Express 5`, `node-postgres (pg) 8.20`
- **Database**: `PostgreSQL (kartoza/postgis)`, `PostGIS`
- **Testing**: `Vitest` (Frontend), `Jest` (Backend)
- **DevOps**: `Docker`, `Docker Compose`
---
## 🚦 Getting Started & Key Commands
### Complete System (Docker)
| Action | Command |
| :--- | :--- |
| **Start Services** | `docker-compose up` |
| **Rebuild & Start** | `docker-compose up --build` |
| **Stop Services** | `docker-compose down` |
| **View Logs** | `docker-compose logs -f` |
| **Access DB (psql)** | `docker-compose exec postgres psql -U line_of_sight -d line_of_sight` |
### Backend Development (`/backend`)
| Action | Command |
| :--- | :--- |
| **Install** | `npm install` |
| **Start (Prod)** | `npm start` |
| **Start (Dev)** | `npm run dev` (Uses nodemon) |
| **Test** | `npm test` |
### Frontend Development (`/frontend`)
| Action | Command |
| :--- | :--- |
| **Install** | `npm install` |
| **Start (Dev)** | `npm run start` (Starts Vite) |
| **Build** | `npm run build` |
| **Test** | `npm run test` (Starts Vitest) |
---
## 📁 Key File Map
- `backend/app/server.js`: Main Express entry point and API route definitions.
- `frontend/src/App.js`: Primary React component containing map logic and state management.
- `frontend/src/services/api.js`: Axios-based API client for backend communication.
- `docker/init.sql`: Database schema definition including PostGIS extension and seed data.
- `docker-compose.yml`: Service orchestration for the entire stack.
---
## 🔧 Development Conventions
- **Node Versioning**: Use `nvm` to manage Node.js. Run `nvm use` in the root directory to switch to Node 24 (specified in `.nvmrc`).
- **Geospatial Standards**: Latitude/Longitude are handled in decimal degrees. Geometry uses `SRID 4326` (WGS 84).
- **API Design**: RESTful endpoints returning JSON. The primary endpoint is `GET /api/line-of-sight`.
- **Styling**: Prefer custom CSS in `frontend/src/styles/` over utility-first frameworks like Tailwind for this specific project.
- **Testing**: Tests are located in `backend/tests/` and `frontend/src/__tests__/`. Use `vitest` (Frontend) and `jest` (Backend).
- **Environment**: Use `.env` files for configuration. `VITE_API_URL` is required for the frontend.
---
## 📊 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.
- [ ] Transition from 2D MapLibre to a 3D globe visualization.
- [ ] Optimize line-of-sight calculations for long-distance paths.
+4 -4
View File
@@ -51,8 +51,8 @@ docker-compose up --build
### 3. Access Application
- **Frontend**: http://localhost:3000
- **Backend API**: http://localhost:3001
- **Frontend**: http://localhost:3050
- **Backend API**: http://localhost:3051
- **Database**: localhost:5432 (PostgreSQL with PostGIS)
## 🎮 How to Use
@@ -219,8 +219,8 @@ npm run dev
### Port Already in Use
```bash
# Find process using port
lsof -i :3000
lsof -i :3001
lsof -i :3050
lsof -i :3051
# Kill process
kill -9 <PID>
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:18-alpine
FROM node:24-alpine
WORKDIR /app
@@ -10,7 +10,7 @@ RUN npm install
COPY . .
# Expose port
EXPOSE 3001
EXPOSE 3051
# Start server
CMD ["npm", "start"]
+162 -49
View File
@@ -1,73 +1,186 @@
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3001;
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());
// 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 }
];
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
};
};
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;
console.log(`Received request: lat=${lat}, lon=${lon}, direction=${direction}, tolerance=${tolerance}`);
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));
}
// 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,
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 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}`);
// 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"
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
}
});
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() });
});
app.listen(PORT, '0.0.0.0', () => {
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 };
+5466
View File
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -6,16 +6,19 @@
"scripts": {
"start": "node app/server.js",
"dev": "nodemon app/server.js",
"test": "jest"
"test": "jest",
"seed-data": "node scripts/import_cities.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"pg": "^8.11.3",
"dotenv": "^16.3.1"
"axios": "^1.13.6",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"pg": "^8.20.0"
},
"devDependencies": {
"nodemon": "^3.0.1",
"jest": "^29.7.0"
"jest": "^30.3.0",
"nodemon": "^3.1.14",
"supertest": "^7.2.2"
}
}
+95
View File
@@ -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();
+60 -28
View File
@@ -1,42 +1,74 @@
/**
* Placeholder test file for Line of Sight Backend
*
* TODO: Add real tests for:
* - API endpoint validation
* - Geospatial calculations
* - Database queries
* - Error handling
*/
const request = require('supertest');
const { app, calculateDestination } = require('../app/server');
// Mock pg Pool
jest.mock('pg', () => {
const mPool = {
query: jest.fn(),
on: jest.fn(),
end: jest.fn(),
};
return { Pool: jest.fn(() => mPool) };
});
const { Pool } = require('pg');
const pool = new Pool();
describe('Line of Sight Backend', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('calculateDestination', () => {
test('should calculate correct destination for North (0 degrees)', () => {
const start = { lat: 0, lon: 0 };
const dest = calculateDestination(start.lat, start.lon, 0, 111.12); // ~1 degree North
expect(dest.lat).toBeCloseTo(1, 1);
expect(dest.lon).toBeCloseTo(0, 1);
});
test('should calculate correct destination for East (90 degrees)', () => {
const start = { lat: 0, lon: 0 };
const dest = calculateDestination(start.lat, start.lon, 90, 111.12); // ~1 degree East at equator
expect(dest.lat).toBeCloseTo(0, 1);
expect(dest.lon).toBeCloseTo(1, 1);
});
});
describe('GET /api/health', () => {
test('should return 200 OK', () => {
// TODO: Implement health check test
expect(true).toBe(true);
test('should return 200 OK', async () => {
const response = await request(app).get('/api/health');
expect(response.status).toBe(200);
expect(response.body.status).toBe('ok');
});
});
describe('GET /api/line-of-sight', () => {
test('should return valid response structure', () => {
// TODO: Implement line of sight API test
expect(true).toBe(true);
test('should return valid response structure', async () => {
// Mock the water checks and the main city query
pool.query
.mockResolvedValueOnce({ rows: [{ has_land: true }] }) // Simplified for tests, it actually calls many times
.mockResolvedValue({ rows: [{ id: 1, name: 'London', population: 9000000, country: 'GB', lat: 51.5, lon: -0.1, distance_off_line_km: 0, distance_from_start_km: 0, pos_on_line: 0 }] });
const response = await request(app)
.get('/api/line-of-sight')
.query({ lat: 51.5, lon: -0.1, direction: 45, tolerance: 50 });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.conurbations).toBeDefined();
expect(response.body.data.line_coordinates).toBeDefined();
});
test('should handle missing parameters', () => {
// TODO: Implement error handling test
expect(true).toBe(true);
});
});
test('should handle database errors', async () => {
pool.query.mockRejectedValue(new Error('DB Error'));
describe('Geospatial Calculations', () => {
test('should calculate great circle path', () => {
// TODO: Implement geospatial calculation tests
expect(true).toBe(true);
});
const response = await request(app)
.get('/api/line-of-sight')
.query({ lat: 51.5, lon: -0.1, direction: 45, tolerance: 50 });
test('should filter cities within tolerance', () => {
// TODO: Implement tolerance filtering tests
expect(true).toBe(true);
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
});
});
});
+11 -14
View File
@@ -1,17 +1,13 @@
version: '3.8'
services:
postgres:
image: postgis/postgis:15-3.3-alpine
image: kartoza/postgis:latest
container_name: line-of-sight-db
environment:
POSTGRES_DB: line_of_sight
POSTGRES_USER: line_of_sight
POSTGRES_PASSWORD: line_of_sight_pass
ports:
- "5432:5432"
- POSTGRES_DB=line_of_sight
- POSTGRES_USER=line_of_sight
- POSTGRES_PASS=line_of_sight_pass
volumes:
- pgdata:/var/lib/postgresql/data
- pgdata:/var/lib/postgresql
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U line_of_sight"]
@@ -24,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
@@ -42,9 +39,9 @@ services:
dockerfile: Dockerfile
container_name: line-of-sight-frontend
ports:
- "3000:3000"
- "3050:3050"
environment:
- REACT_APP_API_URL=http://localhost:3001/api
- VITE_API_URL=/api
depends_on:
- backend
volumes:
Binary file not shown.
+4 -4
View File
@@ -1,4 +1,4 @@
FROM node:18-alpine
FROM node:24-alpine
WORKDIR /app
@@ -10,10 +10,10 @@ RUN npm install
COPY . .
# Expose port
EXPOSE 3000
EXPOSE 3050
# Set environment variable for API URL
ENV REACT_APP_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"]
+107
View File
@@ -0,0 +1,107 @@
import { test, expect } from '@playwright/test';
// MapLibre GL requires WebGL which isn't available in headless Chromium.
// Intercept the module request and return a lightweight mock so React can render.
const MAPLIBRE_MOCK = `
class Map {
constructor(opts) {
this._listeners = {};
Promise.resolve().then(() => this._emit('style.load'));
}
_emit(event) {
(this._listeners[event] || []).forEach(fn => fn());
}
on(event, fn) {
if (!this._listeners[event]) this._listeners[event] = [];
this._listeners[event].push(fn);
return this;
}
off(event, fn) {
this._listeners[event] = (this._listeners[event] || []).filter(l => l !== fn);
return this;
}
remove() {}
getSource() { return null; }
getLayer() { return null; }
addSource() {}
addLayer() {}
removeLayer() {}
removeSource() {}
setSky() {}
setTerrain() {}
setLayoutProperty() {}
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 }; }
}
class Marker {
constructor(opts) {
this._el = (opts && opts.element) || document.createElement('div');
}
setLngLat() { return this; }
addTo() { return this; }
remove() {}
getLngLat() { return { lng: 0, lat: 0 }; }
getElement() { return this._el; }
}
class Popup {
setLngLat() { return this; }
setDOMContent() { return this; }
addTo() { return this; }
remove() {}
}
class LngLatBounds {
extend() { return this; }
}
export default { Map, Marker, Popup, LngLatBounds };
`;
test.beforeEach(async ({ page }) => {
await page.route('**maplibre-gl.js*', async route => {
await route.fulfill({ contentType: 'application/javascript', body: MAPLIBRE_MOCK });
});
});
test.describe('Line of Sight Application', () => {
test('should load the home page and show settings', async ({ page }) => {
await page.goto('/');
await expect(page.locator('text=Line of Sight Settings')).toBeVisible();
await expect(page.locator('label:has-text("Direction")')).toBeVisible();
});
test('should be able to toggle map style', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('h3:text("Line of Sight Settings")', { timeout: 10000 });
const darkButton = page.locator('button:text("Dark")');
await darkButton.click();
await expect(darkButton).toHaveClass(/active-style/);
});
test('should show results when clicking the search button', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('h3:text("Line of Sight Settings")', { timeout: 10000 });
await page.route('**/api/line-of-sight*', async route => {
const json = {
success: true,
data: {
conurbations: [
{ id: 1, name: 'Test City', population: 100000, country: 'TS', lat: 0, lon: 0, distance_km: 10, off_line_km: 1 }
],
line_coordinates: [{ lat: 0, lon: 0 }, { lat: 1, lon: 1 }]
}
};
await route.fulfill({ json });
});
await page.click('button:text("Show Line of Sight")');
await expect(page.locator('text=Conurbations Found')).toBeVisible();
await expect(page.locator('text=Test City')).toBeVisible();
});
});
@@ -11,5 +11,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
+5680
View File
File diff suppressed because it is too large Load Diff
+29 -11
View File
@@ -3,20 +3,38 @@
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"maplibre-gl": "^3.6.2",
"axios": "^1.6.2"
"@turf/turf": "^7.3.4",
"axios": "^1.7.9",
"maplibre-gl": "^5.20.1",
"react": "^19.2.1",
"react-dom": "^19.2.1"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@vitejs/plugin-react": "^4.3.0",
"happy-dom": "^20.8.4",
"vite": "^6.0.0",
"vitest": "^3.0.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"start": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:e2e": "playwright test"
},
"browserslist": {
"production": [">0.2%", "not dead", "not op_mini all"],
"development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'list' : 'html',
use: {
baseURL: 'http://localhost:3050',
trace: 'on-first-retry',
headless: true,
launchOptions: {
args: [
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader-webgl',
],
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3050',
reuseExistingServer: !process.env.CI,
},
});
-243
View File
@@ -1,243 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import apiService from './services/api';
import './styles/App.css';
const APP = () => {
const mapContainerRef = useRef(null);
const mapRef = 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 [mapStyle, setMapStyle] = useState('light'); // 'light' or 'dark'
const [tolerance, setTolerance] = useState(50);
useEffect(() => {
// Initialize MapLibre map
mapRef.current = new maplibregl.Map({
container: mapContainerRef.current,
style: getMapStyle(mapStyle),
center: [-0.1278, 51.5074], // London
zoom: 3,
pitch: 0
});
mapRef.current.on('load', () => {
console.log('Map loaded successfully');
});
mapRef.current.on('click', (e) => {
const { lng, lat } = e.lngLat;
setSelectedPoint({ lat, lon: lng });
// Clear previous line
if (mapRef.current.getSource('line-of-sight')) {
mapRef.current.removeLayer('line-of-sight');
mapRef.current.removeSource('line-of-sight');
}
});
return () => {
mapRef.current.remove();
};
}, []);
useEffect(() => {
// Update map style when toggle changes
if (mapRef.current) {
mapRef.current.setStyle(getMapStyle(mapStyle));
}
}, [mapStyle]);
const getMapStyle = (style) => {
return style === 'dark'
? 'https://demotiles.maplibre.org/style.json'
: 'https://demotiles.maplibre.org/style.json'; // Using same for now, can be customized
};
const handleShowLineOfSight = async () => {
setLoading(true);
try {
const response = await apiService.getLineOfSight(
selectedPoint.lat,
selectedPoint.lon,
direction,
tolerance
);
setLineOfSightData(response.data);
renderLineOnMap(response.data);
} catch (error) {
console.error('Error fetching line of sight:', error);
} finally {
setLoading(false);
}
};
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="controls">
<div className="control-group">
<h3>Line of Sight Settings</h3>
<div className="setting-row">
<label>Start Point:</label>
<span>{selectedPoint.lat.toFixed(4)}, {selectedPoint.lon.toFixed(4)}</span>
</div>
<div className="setting-row">
<label>Direction (0-360°):</label>
<input
type="range"
min="0"
max="360"
value={direction}
onChange={(e) => setDirection(parseInt(e.target.value))}
/>
<span>{direction}°</span>
</div>
<div className="setting-row">
<label>Fuzziness Tolerance (km):</label>
<input
type="number"
value={tolerance}
onChange={(e) => setTolerance(parseInt(e.target.value))}
min="10"
max="200"
/>
<span>{tolerance} km</span>
</div>
<div className="setting-row">
<label>Map Style:</label>
<button onClick={() => setMapStyle('light')}>Light</button>
<button onClick={() => setMapStyle('dark')}>Dark</button>
</div>
<button
className="action-btn"
onClick={handleShowLineOfSight}
disabled={loading}
>
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
</button>
</div>
{lineOfSightData && (
<div className="results-panel">
<h3>Conurbations Found ({lineOfSightData.conurbations.length})</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>City</th>
<th>Population</th>
<th>Distance</th>
</tr>
</thead>
<tbody>
{lineOfSightData.conurbations.slice(0, 10).map((city, index) => (
<tr key={city.id}>
<td>{index + 1}</td>
<td>{city.name}</td>
<td>{(city.population / 1000000).toFixed(1)}M</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>
)}
<div className="instructions">
<h3>How to Use</h3>
<ol>
<li>Click anywhere on the map to select a starting point</li>
<li>Adjust the direction using the slider (0-360°)</li>
<li>Set your fuzziness tolerance (how close cities must be to the line)</li>
<li>Click "Show Line of Sight" to visualize the path and cities</li>
</ol>
</div>
</div>
</div>
);
};
export default APP;
+956
View File
@@ -0,0 +1,956 @@
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';
const CityRow = React.memo(({ city, index, isPlaying, currentCityIndex, isSelected, onClick }) => {
let rowClass = '';
if (isPlaying && currentCityIndex >= 0) {
if (index === currentCityIndex) rowClass = 'current-row';
else if (index < currentCityIndex) rowClass = 'passed-row';
else if (index <= currentCityIndex + 5) rowClass = 'upcoming-row';
} else if (isSelected) {
rowClass = 'selected-row';
}
return (
<tr
id={`city-row-${index}`}
onClick={onClick}
className={rowClass}
>
<td>{index + 1}</td>
<td>{city.name}</td>
<td>{(city.population / 1000).toFixed(0)}k</td>
<td>{city.distance_km}km</td>
</tr>
);
});
const APP = () => {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const startMarkerRef = useRef(null);
const animationRef = useRef(null);
const popupRef = useRef(null);
// Refs mirror state so stale closures (e.g. style.load) always read the current value
const mapProjectionRef = useRef('globe');
const selectedPointRef = useRef({ lat: 51.5074, lon: -0.1278 });
const directionRef = useRef(90);
const lineOfSightDataRef = useRef(null);
const flightProgressRef = useRef(0);
const seekRef = useRef(null);
const currentCityIndexRef = useRef(-1);
const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 });
const [direction, setDirection] = useState(90);
const [lineOfSightData, setLineOfSightData] = useState(null);
const [loading, setLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [apiError, setApiError] = useState(null);
const [flightSpeed, setFlightSpeed] = useState(1.0);
const [mapStyle, setMapStyle] = useState('basic');
const [mapProjection, setMapProjection] = useState('globe');
const [tolerance, setTolerance] = useState(50);
const [selectedCity, setSelectedCity] = useState(null);
const [isLocked, setIsLocked] = useState(false);
const [currentCityIndex, setCurrentCityIndex] = useState(-1);
// Keep refs in sync with state so stale closures always have current values
useEffect(() => { mapProjectionRef.current = mapProjection; }, [mapProjection]);
useEffect(() => { selectedPointRef.current = selectedPoint; }, [selectedPoint]);
useEffect(() => { directionRef.current = direction; }, [direction]);
useEffect(() => { lineOfSightDataRef.current = lineOfSightData; }, [lineOfSightData]);
// Auto-scroll sidebar to current city during flight
useEffect(() => {
if (isPlaying && currentCityIndex >= 0) {
const row = document.getElementById(`city-row-${currentCityIndex}`);
if (row) row.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
}, [currentCityIndex, isPlaying]);
// --- Helpers ---
// Build GeoJSON for the line, splitting at the antimeridian for flat projections
const buildLineGeoJSON = (lineCoords) => {
const coords = lineCoords.map(c => [c.lon, c.lat]);
// Split segments wherever consecutive points cross the antimeridian (±180°)
const segments = [];
let current = [coords[0]];
for (let i = 1; i < coords.length; i++) {
const diff = coords[i][0] - coords[i - 1][0];
if (Math.abs(diff) > 180) {
segments.push(current);
current = [coords[i]];
} else {
current.push(coords[i]);
}
}
segments.push(current);
if (segments.length === 1) {
return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: segments[0] } };
}
return { type: 'Feature', properties: {}, geometry: { type: 'MultiLineString', coordinates: segments } };
};
// Register a subtle chevron arrow image for the symbol layer
const addArrowImage = (map) => {
const size = 20;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.strokeStyle = 'rgba(255, 107, 107, 0.75)';
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
// Chevron pointing right; MapLibre rotates it to follow line direction
ctx.moveTo(size * 0.2, size * 0.22);
ctx.lineTo(size * 0.78, size * 0.5);
ctx.lineTo(size * 0.2, size * 0.78);
ctx.stroke();
// MapLibre v5 requires ImageData, not a raw canvas element
map.addImage('arrow-icon', ctx.getImageData(0, 0, size, size));
};
// --- Map initialisation (runs once) ---
useEffect(() => {
mapRef.current = new maplibregl.Map({
container: mapContainerRef.current,
style: getMapStyle(mapStyle),
center: [-0.1278, 51.5074],
zoom: 2,
pitch: 0,
projection: { type: 'globe' }
});
mapRef.current.on('style.load', () => {
setupMapLayers();
});
return () => {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
if (mapRef.current) mapRef.current.remove();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const setupMapLayers = () => {
const map = mapRef.current;
if (!map) return;
const isGlobe = mapProjectionRef.current === 'globe';
// 1. Register arrow image (cleared on style reload)
addArrowImage(map);
// 2. Sky and terrain — globe only
if (isGlobe) {
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
});
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 });
}
// Read current values from refs to avoid stale closure problems
const currentPoint = selectedPointRef.current;
const currentDirection = directionRef.current;
const currentLineData = lineOfSightDataRef.current;
// 3. Start marker
if (startMarkerRef.current) startMarkerRef.current.remove();
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
.setLngLat([currentPoint.lon, currentPoint.lat])
.addTo(map);
// 4. Preview line source and layer
if (!map.getSource('preview-line')) {
map.addSource('preview-line', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } }
});
map.addLayer({
id: 'preview-line',
type: 'line',
source: 'preview-line',
paint: { 'line-color': '#FF6B6B', 'line-width': 2, 'line-dasharray': [2, 2] }
});
}
// 5. Cities source and layers (Symbol/Circle for high performance & terrain alignment)
if (!map.getSource('cities')) {
map.addSource('cities', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
// City dot (Circle)
map.addLayer({
id: 'cities-circle',
type: 'circle',
source: 'cities',
paint: {
'circle-radius': 6,
'circle-color': '#e74c3c',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
// City label (Symbol)
map.addLayer({
id: 'cities-label',
type: 'symbol',
source: 'cities',
layout: {
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 12,
'text-offset': [0, 1.5],
'text-anchor': 'top',
'text-allow-overlap': false,
'text-ignore-placement': false
},
paint: {
'text-color': '#2c3e50',
'text-halo-color': '#ffffff',
'text-halo-width': 2
}
});
// Handle city clicks
map.on('click', 'cities-circle', (e) => {
const feature = e.features[0];
if (feature) {
const city = JSON.parse(feature.properties.cityData);
setSelectedCity(city);
showCityPopup(city);
}
});
map.on('mouseenter', 'cities-circle', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'cities-circle', () => {
map.getCanvas().style.cursor = '';
});
}
// 6. Restore result data if it exists
if (currentLineData) {
renderLineOnMap(currentLineData);
if (map.getLayer('preview-line')) {
map.setLayoutProperty('preview-line', 'visibility', 'none');
}
} else {
updatePreviewLine(currentPoint, currentDirection);
}
};
// --- Projection change effect ---
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const applyProjection = () => {
if (mapProjection === 'globe') {
map.setProjection({ type: 'globe' });
map.easeTo({ pitch: 0, duration: 1000 });
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
});
if (map.getSource('terrain')) {
map.setTerrain({ source: 'terrain', exaggeration: 1.5 });
}
} else if (mapProjection === 'perspective') {
map.setProjection({ type: 'mercator' });
map.easeTo({ pitch: 60, duration: 1000 });
if (map.getSource('terrain')) {
map.setTerrain({ source: 'terrain', exaggeration: 1.5 });
}
// Remove sky for flat perspective as it can look odd without a true globe
map.setSky(null);
} else {
// Flat mode
map.setProjection({ type: 'mercator' });
map.easeTo({ pitch: 0, duration: 1000 });
map.setTerrain(null);
map.setSky(null);
}
// Re-render line with correct antimeridian handling for this projection
if (lineOfSightData && map.getSource('line-of-sight')) {
map.getSource('line-of-sight').setData(
buildLineGeoJSON(lineOfSightData.line_coordinates)
);
}
};
if (map.isStyleLoaded()) {
applyProjection();
} else {
map.once('style.load', applyProjection);
return () => map.off('style.load', applyProjection);
}
}, [mapProjection]); // eslint-disable-line react-hooks/exhaustive-deps
// --- Click handler (respects isLocked) ---
useEffect(() => {
if (!mapRef.current) return;
const handleClick = (e) => {
if (isLocked) return;
const { lng, lat } = e.lngLat;
// Update ref immediately (state update is async)
selectedPointRef.current = { lat, lon: lng };
setSelectedPoint({ lat, lon: lng });
if (startMarkerRef.current) {
startMarkerRef.current.setLngLat([lng, lat]);
}
syncCitiesToMap([]);
const map = mapRef.current;
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
if (map.getSource('line-of-sight')) {
map.removeLayer('line-of-sight');
map.removeSource('line-of-sight');
}
if (map.getLayer('preview-line')) {
map.setLayoutProperty('preview-line', 'visibility', 'visible');
}
};
mapRef.current.on('click', handleClick);
return () => {
if (mapRef.current) mapRef.current.off('click', handleClick);
};
}, [isLocked]);
// Update preview line when direction or point changes
useEffect(() => {
if (!isLocked) {
updatePreviewLine(selectedPoint, direction);
}
}, [direction, selectedPoint, isLocked]); // eslint-disable-line react-hooks/exhaustive-deps
// --- Map style change ---
useEffect(() => {
if (mapRef.current) {
mapRef.current.setStyle(getMapStyle(mapStyle));
}
}, [mapStyle]); // eslint-disable-line react-hooks/exhaustive-deps
// --- Handlers ---
const handleStartAgain = () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
setIsPlaying(false);
setFlightSpeed(1.0);
}
flightProgressRef.current = 0;
setCurrentCityIndex(-1);
currentCityIndexRef.current = -1;
if (popupRef.current) {
popupRef.current.remove();
popupRef.current = null;
}
setIsLocked(false);
setLineOfSightData(null);
setSelectedCity(null);
setApiError(null);
syncCitiesToMap([]);
if (mapRef.current) {
const map = mapRef.current;
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
if (map.getSource('line-of-sight')) {
map.removeLayer('line-of-sight');
map.removeSource('line-of-sight');
}
if (map.getLayer('preview-line')) {
map.setLayoutProperty('preview-line', 'visibility', 'visible');
}
map.flyTo({ center: [selectedPoint.lon, selectedPoint.lat], zoom: 3, pitch: 0, bearing: 0 });
}
};
const syncCitiesToMap = (cities) => {
const map = mapRef.current;
if (!map || !map.getSource('cities')) return;
const geojson = {
type: 'FeatureCollection',
features: cities.map((city) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [city.lon, city.lat] },
properties: {
name: city.name,
cityData: JSON.stringify(city)
}
}))
};
map.getSource('cities').setData(geojson);
};
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);
// Don't reset currentCityIndex here, so it resumes correctly
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);
let lastFetchedDist = flightProgressRef.current;
let currentProgress = flightProgressRef.current;
// Removed: flightProgressRef.current = 0; - We keep the ref to resume.
let lastTimestamp = performance.now();
let currentSpeedMultiplier = 1.0;
let frameCount = 0;
async function animate(currentTime) {
if (!mapRef.current) return;
// Apply any pending seek
if (seekRef.current !== null) {
currentProgress = seekRef.current;
lastFetchedDist = currentProgress;
lastTimestamp = currentTime;
seekRef.current = null;
}
const deltaTime = currentTime - lastTimestamp;
lastTimestamp = currentTime;
const baseKmPerMs = 0.1;
const pathPoints = lineOfSightDataRef.current.line_coordinates;
const pointIndex = Math.min(
Math.floor((currentProgress / routeDistance) * pathPoints.length),
pathPoints.length - 1
);
const isOverWater = pathPoints[pointIndex]?.is_over_water;
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);
setCurrentCityIndex(-1);
currentCityIndexRef.current = -1;
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 });
// Find nearest city to current position and update sidebar highlight
const cities = lineOfSightDataRef.current.conurbations;
let nearestIdx = -1;
let nearestDist = Infinity;
cities.forEach((city, idx) => {
const d = Math.abs(city.distance_km - currentProgress);
if (d < nearestDist) { nearestDist = d; nearestIdx = idx; }
});
if (nearestDist < 300 && nearestIdx !== currentCityIndexRef.current) {
currentCityIndexRef.current = nearestIdx;
setCurrentCityIndex(nearestIdx);
}
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)
};
syncCitiesToMap(updatedData.conurbations);
return updatedData;
});
}
} catch (e) {
console.error('Error fetching more cities during flight:', e);
}
}
flightProgressRef.current = currentProgress;
animationRef.current = requestAnimationFrame(animate);
}
animationRef.current = requestAnimationFrame(animate);
};
const renderLineOnMap = (data) => {
const map = mapRef.current;
if (!map) return;
// Remove existing layers/source (arrow layer must go before line layer)
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
if (map.getSource('line-of-sight')) {
map.removeLayer('line-of-sight');
map.removeSource('line-of-sight');
}
const projection = mapProjectionRef.current;
syncCitiesToMap([]);
map.addSource('line-of-sight', {
type: 'geojson',
data: buildLineGeoJSON(data.line_coordinates)
});
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 }
});
// Subtle periodic directional arrows along the line
map.addLayer({
id: 'line-of-sight-arrows',
type: 'symbol',
source: 'line-of-sight',
layout: {
'symbol-placement': 'line',
'symbol-spacing': 300,
'icon-image': 'arrow-icon',
'icon-size': 1.0,
'icon-allow-overlap': true,
'icon-ignore-placement': true
}
});
addArrowImage(map);
syncCitiesToMap(data.conurbations);
// Zoom to show the full path from the start point
map.flyTo({
center: [data.line_coordinates[0].lon, data.line_coordinates[0].lat],
zoom: 2,
pitch: 0,
bearing: 0
});
};
const updatePreviewLine = (point, brng) => {
const map = mapRef.current;
if (!map || !map.getSource('preview-line')) return;
const path = [];
const steps = 160;
const totalDistance = 40074; // Full Earth circumference (km)
for (let i = 0; i <= steps; i++) {
const dist = (totalDistance * i) / steps;
path.push(calculateDestination(point.lat, point.lon, brng, dist));
}
map.getSource('preview-line').setData(
buildLineGeoJSON(path)
);
};
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 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 &copy; Esri &mdash; 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
}]
};
}
if (style === 'map') {
return 'https://tiles.openfreemap.org/styles/liberty';
}
// 'basic' — clean MapLibre demo tiles, no labels
return 'https://demotiles.maplibre.org/style.json';
};
const handleLocateMe = () => {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude: lat, longitude: lon } = pos.coords;
selectedPointRef.current = { lat, lon };
setSelectedPoint({ lat, lon });
if (startMarkerRef.current) {
startMarkerRef.current.setLngLat([lon, lat]);
}
mapRef.current?.flyTo({ center: [lon, lat], zoom: 5 });
},
(err) => console.error('Geolocation error:', err)
);
};
const handleShowLineOfSight = async () => {
setLoading(true);
setSelectedCity(null);
setApiError(null);
try {
const response = await apiService.getLineOfSight(
selectedPoint.lat,
selectedPoint.lon,
direction,
tolerance
);
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);
const msg = error.response?.data?.error || error.message || 'Failed to fetch route.';
setApiError(`Error: ${msg}`);
} finally {
setLoading(false);
// Reset flight progress for a new line calculation
flightProgressRef.current = 0;
setCurrentCityIndex(-1);
currentCityIndexRef.current = -1;
}
};
const PROJECTIONS = [
{ id: 'globe', label: 'Globe' },
{ id: 'perspective', label: 'Perspective' },
{ id: 'flat', label: 'Flat' }
];
return (
<div className="app-container">
<div className="map-container" ref={mapContainerRef}>
{!isLocked && (
<button
className="locate-btn"
onClick={handleLocateMe}
title="Use my current location"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<line x1="12" y1="2" x2="12" y2="6"/>
<line x1="12" y1="18" x2="12" y2="22"/>
<line x1="2" y1="12" x2="6" y2="12"/>
<line x1="18" y1="12" x2="22" y2="12"/>
</svg>
</button>
)}
</div>
<div className="controls">
<div className={`control-group ${isLocked ? 'disabled-controls' : ''}`}>
<h3>Line of Sight Settings</h3>
<div className="setting-row">
<label>Start Point:</label>
<span>{selectedPoint.lat.toFixed(4)}, {selectedPoint.lon.toFixed(4)}</span>
</div>
<div className="setting-row">
<label>Direction (0-360°):</label>
<input
type="range"
min="0"
max="360"
value={direction}
disabled={isLocked}
onChange={(e) => setDirection(parseInt(e.target.value))}
/>
<span>{direction}°</span>
</div>
<div className="setting-row">
<label>Fuzziness Tolerance (km):</label>
<input
type="number"
value={tolerance}
disabled={isLocked}
onChange={(e) => setTolerance(parseInt(e.target.value))}
min="10"
max="200"
/>
<span>{tolerance} km</span>
</div>
<div className="setting-row">
<label>Map Style:</label>
<button className={mapStyle === 'basic' ? 'active-style' : ''} onClick={() => setMapStyle('basic')}>Basic</button>
<button className={mapStyle === 'map' ? 'active-style' : ''} onClick={() => setMapStyle('map')}>Map</button>
<button className={mapStyle === 'satellite' ? 'active-style' : ''} onClick={() => setMapStyle('satellite')}>Satellite</button>
</div>
<div className="setting-row projection-row">
<label>Projection:</label>
<div className="projection-buttons">
{PROJECTIONS.map(p => (
<button
key={p.id}
className={mapProjection === p.id ? 'active-style' : ''}
onClick={() => setMapProjection(p.id)}
>
{p.label}
</button>
))}
</div>
</div>
{apiError && (
<div className="error-banner">
{apiError}
</div>
)}
{!isLocked ? (
<button className="action-btn" onClick={handleShowLineOfSight} disabled={loading}>
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
</button>
) : (
<>
<div className="fly-controls">
<button
className={`fly-button ${isPlaying ? 'pause' : 'play'}`}
onClick={startFlyOver}
disabled={!lineOfSightData}
>
{isPlaying ? `Pause Flyover (${flightSpeed.toFixed(1)}x)` : (flightProgressRef.current > 0 ? 'Resume Flyover' : 'Start Flyover')}
</button>
<button
className="reset-flight-btn"
onClick={() => {
cancelAnimationFrame(animationRef.current);
setIsPlaying(false);
setFlightSpeed(1.0);
flightProgressRef.current = 0;
setCurrentCityIndex(-1);
currentCityIndexRef.current = -1;
// Move camera back to start
if (mapRef.current) {
mapRef.current.flyTo({
center: [selectedPoint.lon, selectedPoint.lat],
zoom: 4,
pitch: 0,
bearing: 0
});
}
}}
disabled={!lineOfSightData || (flightProgressRef.current === 0 && !isPlaying)}
title="Reset flight to beginning"
>
Reset
</button>
</div>
<button className="action-btn-secondary" onClick={handleStartAgain}>
🔄 Start Again
</button>
</>
)}
</div>
{lineOfSightData && (
<div className="results-panel">
<h3>Cities Along Route ({lineOfSightData.conurbations.length})</h3>
{isPlaying && currentCityIndex >= 0 && (
<div className="now-passing">
Now passing: <strong>{lineOfSightData.conurbations[currentCityIndex]?.name}</strong>
</div>
)}
<p className="hint">{isPlaying ? 'Click a city to jump to it' : 'Click a city for details'}</p>
<table>
<thead>
<tr>
<th>#</th>
<th>City</th>
<th>Pop.</th>
<th>Dist.</th>
</tr>
</thead>
<tbody>
{lineOfSightData.conurbations.map((city, index) => (
<CityRow
key={city.id}
city={city}
index={index}
isPlaying={isPlaying}
currentCityIndex={currentCityIndex}
isSelected={selectedCity?.id === city.id}
onClick={() => {
setSelectedCity(city);
showCityPopup(city);
mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 });
if (isPlaying) {
seekRef.current = city.distance_km;
}
}}
/>
))}
</tbody>
</table>
</div>
)}
<div className="instructions">
<h3>How to Use</h3>
<ol>
<li>Click anywhere on the map to select a starting point</li>
<li>Adjust the direction using the slider (0-360°)</li>
<li>Set your fuzziness tolerance (how close cities must be to the line)</li>
<li>Click "Show Line of Sight" to visualize the path and cities</li>
</ol>
</div>
</div>
</div>
);
};
export default APP;
-53
View File
@@ -1,53 +0,0 @@
/**
* Placeholder test file for Line of Sight Frontend
*
* TODO: Add real tests for:
* - Map component rendering
* - Direction selector functionality
* - API integration
* - User interactions
* - Line of sight visualization
*/
describe('Line of Sight Frontend', () => {
describe('App Component', () => {
test('should render map container', () => {
// TODO: Implement component rendering test
expect(true).toBe(true);
});
test('should display direction selector', () => {
// TODO: Implement direction selector test
expect(true).toBe(true);
});
test('should handle map click events', () => {
// TODO: Implement click event test
expect(true).toBe(true);
});
});
describe('API Integration', () => {
test('should fetch line of sight data', () => {
// TODO: Implement API fetch test
expect(true).toBe(true);
});
test('should handle API errors gracefully', () => {
// TODO: Implement error handling test
expect(true).toBe(true);
});
});
describe('UI Components', () => {
test('should toggle map style between light/dark', () => {
// TODO: Implement style toggle test
expect(true).toBe(true);
});
test('should display conurbation results table', () => {
// TODO: Implement results table test
expect(true).toBe(true);
});
});
});
+112
View File
@@ -0,0 +1,112 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi, describe, test, expect, beforeEach } from 'vitest';
import App from '../App';
import apiService from '../services/api';
// Mock MapLibre GL
vi.mock('maplibre-gl', () => {
const mInstance = {
on: vi.fn(),
off: vi.fn(),
remove: vi.fn(),
addSource: vi.fn(),
removeSource: vi.fn(),
addLayer: vi.fn(),
removeLayer: vi.fn(),
getSource: vi.fn(() => ({ setData: vi.fn() })),
getLayer: vi.fn(),
setLayoutProperty: vi.fn(),
setStyle: vi.fn(),
flyTo: vi.fn(),
jumpTo: vi.fn(),
fitBounds: vi.fn(),
isStyleLoaded: vi.fn(() => true),
setSky: vi.fn(),
setTerrain: vi.fn(),
setProjection: vi.fn(),
hasImage: vi.fn(() => false),
addImage: vi.fn(),
};
return {
default: {
Map: vi.fn(() => mInstance),
Marker: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn().mockReturnThis(),
getElement: vi.fn(() => {
const el = document.createElement('div');
// Add a simple way to trigger click for tests if needed
return el;
}),
})),
Popup: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
setDOMContent: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
})),
LngLatBounds: vi.fn(() => ({
extend: vi.fn().mockReturnThis(),
})),
MercatorCoordinate: {
fromLngLat: vi.fn(() => ({ x: 0, y: 0, z: 0 })),
}
}
};
});
// Mock apiService
vi.mock('../services/api', () => ({
default: {
getLineOfSight: vi.fn(),
healthCheck: vi.fn(),
}
}));
describe('App Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('renders the application title', () => {
render(<App />);
expect(screen.getByText(/Line of Sight Settings/i)).toBeInTheDocument();
});
test('renders initial controls', () => {
render(<App />);
// Use role or more specific text to avoid duplicates
expect(screen.getByText(/Direction \(0-360°\):/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Show Line of Sight/i })).toBeInTheDocument();
});
test('calls API when clicking "Show Line of Sight"', async () => {
apiService.getLineOfSight.mockResolvedValue({
data: {
success: true,
data: {
conurbations: [],
line_coordinates: [{ lat: 0, lon: 0 }]
}
}
});
render(<App />);
const button = screen.getByRole('button', { name: /Show Line of Sight/i });
fireEvent.click(button);
await waitFor(() => {
expect(apiService.getLineOfSight).toHaveBeenCalled();
});
});
test('toggles map style', () => {
render(<App />);
const darkButton = screen.getByRole('button', { name: /Dark/i });
fireEvent.click(darkButton);
expect(darkButton).toHaveClass('active-style');
});
});
+2 -5
View File
@@ -1,13 +1,10 @@
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_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) => {
+4
View File
@@ -0,0 +1,4 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Global mocks if needed
+340 -29
View File
@@ -3,6 +3,7 @@
height: 100vh;
width: 100vw;
overflow: hidden;
position: relative;
}
.map-container {
@@ -12,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;
@@ -19,6 +44,7 @@
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
overflow-y: auto;
z-index: 2;
position: relative;
}
.control-group {
@@ -62,6 +88,7 @@
.setting-row span {
font-weight: bold;
color: #2c3e50;
min-width: 40px;
}
.setting-row button {
@@ -78,6 +105,12 @@
background: #e9ecef;
}
.setting-row button.active-style {
background: #34495e;
color: white;
border-color: #2c3e50;
}
.action-btn {
width: 100%;
padding: 12px;
@@ -101,44 +134,193 @@
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 {
width: 100%;
border-collapse: collapse;
font-size: 13px;
table-layout: fixed;
}
.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;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-panel tr {
will-change: transform, background-color;
}
.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 {
@@ -172,41 +354,67 @@
margin-bottom: 8px;
}
.city-marker {
display: flex;
flex-direction: column;
align-items: center;
.map-info-popup {
padding: 5px;
font-family: sans-serif;
}
.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);
.popup-header {
border-bottom: 1px solid #eee;
padding-bottom: 5px;
margin-bottom: 5px;
color: #2c3e50;
font-size: 14px;
}
.marker-label {
background: white;
.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;
}
.projection-row {
align-items: flex-start;
}
.projection-buttons {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 2;
}
.projection-buttons button {
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;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.marker-pop {
.projection-buttons button:hover {
background: #e9ecef;
}
.projection-buttons button.active-style {
background: #34495e;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
margin-top: 2px;
white-space: nowrap;
border-color: #2c3e50;
}
@media (max-width: 768px) {
@@ -223,4 +431,107 @@
.map-container {
height: 60vh;
}
.info-panel-modal {
left: 10px;
right: 10px;
width: auto;
bottom: 20px;
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;
}
+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./src/setupTests.js'],
exclude: ['**/e2e/**', '**/node_modules/**'],
},
server: {
port: 3050,
host: '0.0.0.0',
allowedHosts: true,
proxy: {
'/api': {
target: 'http://backend:3051',
changeOrigin: true
}
}
},
build: {
outDir: 'build',
target: 'esnext',
},
optimizeDeps: {
esbuildOptions: {
target: 'esnext',
},
},
});
+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."