Compare commits
19 Commits
b40116b56f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bd5f5b98cc | |||
| 205e2d5be5 | |||
| 86aefba065 | |||
| 228032c913 | |||
| cac76a2f69 | |||
| bd9e92f304 | |||
| 4326109722 | |||
| 701904dca7 | |||
| 25f3a09374 | |||
| f89bda232e | |||
| 259fdf1e10 | |||
| 8be3e04803 | |||
| f0767c798c | |||
| ad07a70125 | |||
| e764efb189 | |||
| af578aaff2 | |||
| abae851315 | |||
| c90fc37d0d | |||
| 4f7143fc4f |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(npx playwright:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 :3001, 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 :3001
|
||||||
|
|
||||||
|
# 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 (0–360°) 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
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 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.
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
# Line of Sight 🌍
|
||||||
|
|
||||||
|
Interactive web application that visualizes lines of sight around the Earth, showing major conurbations along any selected path.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Interactive Map**: Click anywhere to select starting coordinates
|
||||||
|
- **360° Direction Selector**: Choose any direction with compass-style control
|
||||||
|
- **Fuzziness Tolerance**: Adjustable search radius around the line of sight
|
||||||
|
- **Tube-Line Visualization**: Shows up to 20 major cities along the path
|
||||||
|
- **Real-time Rendering**: Instant feedback on map with city markers
|
||||||
|
- **Map Style Toggle**: Switch between light and dark themes
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Framework**: React 18
|
||||||
|
- **Map Library**: MapLibre GL JS
|
||||||
|
- **HTTP Client**: Axios
|
||||||
|
- **Styling**: Custom CSS
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Runtime**: Node.js 18
|
||||||
|
- **Framework**: Express.js
|
||||||
|
- **Database**: PostgreSQL 15 with PostGIS 3.3
|
||||||
|
|
||||||
|
### DevOps
|
||||||
|
- **Containerization**: Docker + Docker Compose
|
||||||
|
- **Database**: PostGIS spatial extension
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
- Git (for repository access)
|
||||||
|
|
||||||
|
## 🚦 Getting Started
|
||||||
|
|
||||||
|
### 1. Clone Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://repos.retroweb.dev/ai-zone/line-of-sight.git
|
||||||
|
cd line-of-sight
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Access Application
|
||||||
|
|
||||||
|
- **Frontend**: http://localhost:3050
|
||||||
|
- **Backend API**: http://localhost:3001
|
||||||
|
- **Database**: localhost:5432 (PostgreSQL with PostGIS)
|
||||||
|
|
||||||
|
## 🎮 How to Use
|
||||||
|
|
||||||
|
1. **Select Point**: Click anywhere on the map to set your starting coordinate
|
||||||
|
2. **Set Direction**: Use the slider to choose 0-360° direction
|
||||||
|
3. **Adjust Tolerance**: Set fuzziness (how close cities must be to the line)
|
||||||
|
4. **Show Line of Sight**: Click the button to visualize the path
|
||||||
|
5. **View Results**: See up to 20 major conurbations along the path
|
||||||
|
|
||||||
|
## 🧪 Running Tests
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
```bash
|
||||||
|
docker-compose exec backend npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
```bash
|
||||||
|
docker-compose exec frontend npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 API Endpoints
|
||||||
|
|
||||||
|
### GET /api/line-of-sight
|
||||||
|
Returns conurbations along a line of sight.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `lat` (float): Starting latitude
|
||||||
|
- `lon` (float): Starting longitude
|
||||||
|
- `direction` (int): Direction in degrees (0-360)
|
||||||
|
- `tolerance` (int): Fuzziness tolerance in km (default: 50)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"start_point": { "lat": 51.5074, "lon": -0.1278 },
|
||||||
|
"direction": 45,
|
||||||
|
"tolerance_km": 50,
|
||||||
|
"conurbations": [...],
|
||||||
|
"line_coordinates": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/health
|
||||||
|
Health check endpoint.
|
||||||
|
|
||||||
|
## 🐳 Docker Commands
|
||||||
|
|
||||||
|
### Start Services
|
||||||
|
```bash
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start in Background
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop Services
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild and Start
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Database
|
||||||
|
```bash
|
||||||
|
docker-compose exec postgres psql -U line_of_sight -d line_of_sight
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
line_of_sight/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ └── server.js # Express API server
|
||||||
|
│ ├── tests/ # Backend tests
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── package.json
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.js # Main React component
|
||||||
|
│ │ ├── services/ # API client
|
||||||
|
│ │ └── styles/ # CSS files
|
||||||
|
│ ├── public/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── package.json
|
||||||
|
├── docker/
|
||||||
|
│ └── init.sql # Database initialization
|
||||||
|
├── docker-compose.yml # Container orchestration
|
||||||
|
├── README.md # This file
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧩 Development
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Development
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Current Status
|
||||||
|
|
||||||
|
**MVP Version 1.0**
|
||||||
|
|
||||||
|
- ✅ Interactive map with coordinate selection
|
||||||
|
- ✅ Direction selector (0-360°)
|
||||||
|
- ✅ Fuzziness tolerance control
|
||||||
|
- ✅ Mock API returning 20 conurbations
|
||||||
|
- ✅ Line visualization on map
|
||||||
|
- ✅ City markers with popup info
|
||||||
|
- ⏳ Real geospatial calculations (Phase 2)
|
||||||
|
- ⏳ Natural Earth data import (Phase 2)
|
||||||
|
- ⏳ GeoNames integration (Phase 3)
|
||||||
|
|
||||||
|
## 🔮 Roadmap
|
||||||
|
|
||||||
|
### Phase 1: MVP ✅
|
||||||
|
- Basic map interface
|
||||||
|
- Dummy API endpoints
|
||||||
|
- Docker containerization
|
||||||
|
|
||||||
|
### Phase 2: Real Geospatial
|
||||||
|
- Great circle calculations
|
||||||
|
- PostGIS queries with ST_DWithin()
|
||||||
|
- Natural Earth data import
|
||||||
|
|
||||||
|
### Phase 3: Enhanced Features
|
||||||
|
- GeoNames dataset integration
|
||||||
|
- 3D globe visualization
|
||||||
|
- Distance calculations
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
### Phase 4: Production Ready
|
||||||
|
- User authentication
|
||||||
|
- Saved searches
|
||||||
|
- Export functionality
|
||||||
|
- Mobile optimization
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
```bash
|
||||||
|
# Find process using port
|
||||||
|
lsof -i :3050
|
||||||
|
lsof -i :3001
|
||||||
|
|
||||||
|
# Kill process
|
||||||
|
kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
```bash
|
||||||
|
# Check if database is running
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Restart database
|
||||||
|
docker-compose restart postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Issues
|
||||||
|
```bash
|
||||||
|
# Clean and rebuild
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Create a feature branch
|
||||||
|
2. Make your changes
|
||||||
|
3. Add tests for new functionality
|
||||||
|
4. Ensure all tests pass
|
||||||
|
5. Submit a Pull Request
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues or questions, please open an issue on the repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built with ❤️ by Agent Zero*
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+120
-52
@@ -1,66 +1,130 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const { Pool } = require('pg');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Database connection pool
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight'
|
||||||
|
});
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Mock conurbation data for MVP
|
// Helper to calculate destination point given start, bearing, and distance (km)
|
||||||
const MOCK_CONURBATIONS = [
|
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||||
{ id: 1, name: "London", population: 9000000, distance_km: 0, lat: 51.5074, lon: -0.1278 },
|
const R = 6371;
|
||||||
{ id: 2, name: "Paris", population: 2161000, distance_km: 344, lat: 48.8566, lon: 2.3522 },
|
const brng = (bearing * Math.PI) / 180;
|
||||||
{ id: 3, name: "Berlin", population: 3644000, distance_km: 878, lat: 52.5200, lon: 13.4050 },
|
const φ1 = (lat * Math.PI) / 180;
|
||||||
{ id: 4, name: "Warsaw", population: 1793000, distance_km: 1200, lat: 52.2297, lon: 21.0122 },
|
const λ1 = (lon * Math.PI) / 180;
|
||||||
{ id: 5, name: "Moscow", population: 12506000, distance_km: 2063, lat: 55.7558, lon: 37.6173 },
|
const δ = distance / R;
|
||||||
{ 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 }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mock API endpoint - returns dummy conurbations based on input coordinates
|
const φ2 = Math.asin(
|
||||||
app.get('/api/line-of-sight', (req, res) => {
|
Math.sin(φ1) * Math.cos(δ) +
|
||||||
|
Math.cos(φ1) * Math.sin(δ) * Math.cos(brng)
|
||||||
|
);
|
||||||
|
const λ2 =
|
||||||
|
λ1 +
|
||||||
|
Math.atan2(
|
||||||
|
Math.sin(brng) * Math.sin(δ) * Math.cos(φ1),
|
||||||
|
Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: (φ2 * 180) / Math.PI,
|
||||||
|
lon: (((λ2 * 180) / Math.PI + 540) % 360) - 180
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Real API endpoint - uses PostGIS for spatial queries
|
||||||
|
app.get('/api/line-of-sight', async (req, res) => {
|
||||||
const { lat, lon, direction, tolerance } = req.query;
|
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;
|
||||||
|
|
||||||
// Return mock data for MVP
|
console.log(`Processing real request: lat=${startLat}, lon=${startLon}, bearing=${bearing}, tolerance=${toleranceKm}`);
|
||||||
res.json({
|
|
||||||
success: true,
|
try {
|
||||||
data: {
|
// Generate path points for visualization and spatial query
|
||||||
start_point: { lat: parseFloat(lat) || 51.5074, lon: parseFloat(lon) || -0.1278 },
|
const pathPoints = [];
|
||||||
direction: parseInt(direction) || 45,
|
const totalDistance = 20000;
|
||||||
tolerance_km: parseInt(tolerance) || 50,
|
const steps = 80; // More steps for smoother speed transition
|
||||||
conurbations: MOCK_CONURBATIONS.slice(0, 20),
|
|
||||||
line_coordinates: [
|
for (let i = 0; i <= steps; i++) {
|
||||||
{ lat: 51.5074, lon: -0.1278 },
|
const dist = (totalDistance * i) / steps;
|
||||||
{ lat: 48.8566, lon: 2.3522 },
|
pathPoints.push(calculateDestination(startLat, startLon, bearing, dist));
|
||||||
{ lat: 52.5200, lon: 13.4050 },
|
}
|
||||||
{ lat: 55.7558, lon: 37.6173 },
|
|
||||||
{ lat: 43.2220, lon: 76.8512 },
|
// Batch check for 'over water' status for all path points
|
||||||
{ lat: 28.6139, lon: 77.2090 },
|
// We'll consider a point 'over water' if no city is within 500km
|
||||||
{ lat: 13.7563, lon: 100.5018 },
|
const waterChecks = await Promise.all(pathPoints.map(async (p) => {
|
||||||
{ lat: -6.2088, lon: 106.8456 },
|
const checkQuery = `
|
||||||
{ lat: 35.6762, lon: 139.6503 },
|
SELECT EXISTS (
|
||||||
{ lat: 51.5074, lon: -0.1278 } // Complete the circle
|
SELECT 1 FROM cities
|
||||||
]
|
WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 500000)
|
||||||
},
|
LIMIT 1
|
||||||
message: "Mock data returned for MVP - Real geospatial calculations coming soon"
|
) as has_land;
|
||||||
});
|
`;
|
||||||
|
const res = await pool.query(checkQuery, [p.lon, p.lat]);
|
||||||
|
return !res.rows[0].has_land;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pathPointsWithWater = pathPoints.map((p, i) => ({
|
||||||
|
...p,
|
||||||
|
is_over_water: waterChecks[i]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lineWKT = `LINESTRING(${pathPoints.map(p => `${p.lon} ${p.lat}`).join(',')})`;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
WITH path AS (
|
||||||
|
SELECT ST_GeogFromText($1) as route,
|
||||||
|
ST_MakePoint($3, $4)::geography as start_node
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
population,
|
||||||
|
country,
|
||||||
|
ST_Y(geom::geometry) as lat,
|
||||||
|
ST_X(geom::geometry) as lon,
|
||||||
|
ST_Distance(geom, (SELECT route FROM path)) / 1000 as distance_off_line_km,
|
||||||
|
ST_Distance(geom, (SELECT start_node FROM path)) / 1000 as distance_from_start_km,
|
||||||
|
ST_LineLocatePoint((SELECT route FROM path)::geometry, geom::geometry) as pos_on_line
|
||||||
|
FROM cities
|
||||||
|
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
|
||||||
|
ORDER BY pos_on_line ASC
|
||||||
|
LIMIT 200;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
start_point: { lat: startLat, lon: startLon },
|
||||||
|
direction: bearing,
|
||||||
|
tolerance_km: toleranceKm,
|
||||||
|
conurbations: result.rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
name: row.name || 'Unknown',
|
||||||
|
country: row.country || 'Unknown',
|
||||||
|
distance_km: Math.round(row.distance_from_start_km),
|
||||||
|
off_line_km: Math.round(row.distance_off_line_km)
|
||||||
|
})),
|
||||||
|
line_coordinates: pathPointsWithWater
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Database query error:', err);
|
||||||
|
res.status(500).json({ success: false, error: 'Database query failed' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
@@ -68,6 +132,10 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
if (require.main === module) {
|
||||||
console.log(`Line of Sight Backend running on port ${PORT}`);
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
});
|
console.log(`Line of Sight Backend running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { app, calculateDestination };
|
||||||
|
|||||||
Generated
+5466
File diff suppressed because it is too large
Load Diff
+10
-7
@@ -6,16 +6,19 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node app/server.js",
|
"start": "node app/server.js",
|
||||||
"dev": "nodemon app/server.js",
|
"dev": "nodemon app/server.js",
|
||||||
"test": "jest"
|
"test": "jest",
|
||||||
|
"seed-data": "node scripts/import_cities.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"axios": "^1.13.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.6",
|
||||||
"pg": "^8.11.3",
|
"dotenv": "^17.3.1",
|
||||||
"dotenv": "^16.3.1"
|
"express": "^5.2.1",
|
||||||
|
"pg": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1",
|
"jest": "^30.3.0",
|
||||||
"jest": "^29.7.0"
|
"nodemon": "^3.1.14",
|
||||||
|
"supertest": "^7.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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', 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', 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 database errors', async () => {
|
||||||
|
pool.query.mockRejectedValue(new Error('DB Error'));
|
||||||
|
|
||||||
|
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(500);
|
||||||
|
expect(response.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+9
-7
@@ -2,16 +2,18 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgis/postgis:15-3.3-alpine
|
image: kartoza/postgis:latest
|
||||||
|
platform: linux/arm64
|
||||||
container_name: line-of-sight-db
|
container_name: line-of-sight-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: line_of_sight
|
- POSTGRES_DB=line_of_sight
|
||||||
POSTGRES_USER: line_of_sight
|
- POSTGRES_USER=line_of_sight
|
||||||
POSTGRES_PASSWORD: line_of_sight_pass
|
- POSTGRES_PASS=line_of_sight_pass
|
||||||
|
- ALLOW_IP_RANGE=0.0.0.0/0
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql
|
||||||
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
|
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U line_of_sight"]
|
test: ["CMD-SHELL", "pg_isready -U line_of_sight"]
|
||||||
@@ -42,9 +44,9 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: line-of-sight-frontend
|
container_name: line-of-sight-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3050:3050"
|
||||||
environment:
|
environment:
|
||||||
- REACT_APP_API_URL=http://localhost:3001/api
|
- VITE_API_URL=http://localhost:3001/api
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@ RUN npm install
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 3050
|
||||||
|
|
||||||
# Set environment variable for API URL
|
# Set environment variable for API URL
|
||||||
ENV REACT_APP_API_URL=http://localhost:3001/api
|
ENV VITE_API_URL=http://localhost:3001/api
|
||||||
|
|
||||||
# Start development server
|
# Start development server
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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')); }
|
||||||
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/index.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Generated
+5680
File diff suppressed because it is too large
Load Diff
+29
-11
@@ -3,20 +3,38 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"@turf/turf": "^7.3.4",
|
||||||
"react-dom": "^18.2.0",
|
"axios": "^1.7.9",
|
||||||
"react-scripts": "5.0.1",
|
"maplibre-gl": "^5.20.1",
|
||||||
"maplibre-gl": "^3.6.2",
|
"react": "^19.2.1",
|
||||||
"axios": "^1.6.2"
|
"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": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "vite",
|
||||||
"build": "react-scripts build",
|
"build": "vite build",
|
||||||
"test": "react-scripts test",
|
"preview": "vite preview",
|
||||||
"eject": "react-scripts eject"
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [">0.2%", "not dead", "not op_mini all"],
|
"production": [
|
||||||
"development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]
|
">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
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
@@ -0,0 +1,686 @@
|
|||||||
|
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 APP = () => {
|
||||||
|
const mapContainerRef = useRef(null);
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const startMarkerRef = useRef(null);
|
||||||
|
const cityMarkersRef = useRef([]);
|
||||||
|
const animationRef = useRef(null);
|
||||||
|
const popupRef = useRef(null);
|
||||||
|
const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 });
|
||||||
|
const [direction, setDirection] = useState(45);
|
||||||
|
const [lineOfSightData, setLineOfSightData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [flightSpeed, setFlightSpeed] = useState(1.0);
|
||||||
|
const [mapStyle, setMapStyle] = useState('light'); // 'light' or 'dark'
|
||||||
|
const [tolerance, setTolerance] = useState(50);
|
||||||
|
const [selectedCity, setSelectedCity] = useState(null);
|
||||||
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize MapLibre map - ONLY ONCE
|
||||||
|
mapRef.current = new maplibregl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: getMapStyle(mapStyle),
|
||||||
|
center: [-0.1278, 51.5074], // London
|
||||||
|
zoom: 2,
|
||||||
|
pitch: 0,
|
||||||
|
projection: 'globe' // Enable 3D Globe
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current.on('style.load', () => {
|
||||||
|
console.log('Map style loaded or changed');
|
||||||
|
setupMapLayers();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||||
|
if (mapRef.current) mapRef.current.remove();
|
||||||
|
};
|
||||||
|
}, []); // Run only once
|
||||||
|
|
||||||
|
const setupMapLayers = () => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
// 1. Add atmosphere/sky effects
|
||||||
|
map.setSky({
|
||||||
|
'sky-color': '#199EF3',
|
||||||
|
'sky-horizon-blend': 0.5,
|
||||||
|
'horizon-color': '#ffffff',
|
||||||
|
'horizon-fog-blend': 0.5,
|
||||||
|
'fog-color': '#add8e6',
|
||||||
|
'fog-ground-blend': 0.5
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Add terrain source for 3D effect
|
||||||
|
if (!map.getSource('terrain')) {
|
||||||
|
map.addSource('terrain', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
|
||||||
|
encoding: 'terrarium',
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 15
|
||||||
|
});
|
||||||
|
}
|
||||||
|
map.setTerrain({ source: 'terrain', exaggeration: 1.5 });
|
||||||
|
|
||||||
|
// 3. Initialize start marker
|
||||||
|
if (startMarkerRef.current) startMarkerRef.current.remove();
|
||||||
|
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
|
||||||
|
.setLngLat([selectedPoint.lon, selectedPoint.lat])
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
// 4. Initialize preview line source and layer
|
||||||
|
if (!map.getSource('preview-line')) {
|
||||||
|
map.addSource('preview-line', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'Feature',
|
||||||
|
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. Restore results line and city markers if they existed
|
||||||
|
if (lineOfSightData) {
|
||||||
|
renderLineOnMap(lineOfSightData);
|
||||||
|
// Hide preview if results are shown
|
||||||
|
if (map.getLayer('preview-line')) {
|
||||||
|
map.setLayoutProperty('preview-line', 'visibility', 'none');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatePreviewLine(selectedPoint, direction);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separate effect for the click listener that respects isLocked
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
// Don't allow moving start point if locked
|
||||||
|
if (isLocked) return;
|
||||||
|
|
||||||
|
const { lng, lat } = e.lngLat;
|
||||||
|
setSelectedPoint({ lat, lon: lng });
|
||||||
|
|
||||||
|
if (startMarkerRef.current) {
|
||||||
|
startMarkerRef.current.setLngLat([lng, lat]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear previous final results when moving start point
|
||||||
|
clearCityMarkers();
|
||||||
|
if (mapRef.current.getSource('line-of-sight')) {
|
||||||
|
mapRef.current.removeLayer('line-of-sight');
|
||||||
|
mapRef.current.removeSource('line-of-sight');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mapRef.current.on('click', handleClick);
|
||||||
|
return () => {
|
||||||
|
if (mapRef.current) mapRef.current.off('click', handleClick);
|
||||||
|
};
|
||||||
|
}, [isLocked]);
|
||||||
|
|
||||||
|
const handleStartAgain = () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setFlightSpeed(1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (popupRef.current) {
|
||||||
|
popupRef.current.remove();
|
||||||
|
popupRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLocked(false);
|
||||||
|
setLineOfSightData(null);
|
||||||
|
setSelectedCity(null);
|
||||||
|
clearCityMarkers();
|
||||||
|
|
||||||
|
if (mapRef.current) {
|
||||||
|
if (mapRef.current.getSource('line-of-sight')) {
|
||||||
|
mapRef.current.removeLayer('line-of-sight');
|
||||||
|
mapRef.current.removeSource('line-of-sight');
|
||||||
|
}
|
||||||
|
// Re-enable preview line if it was hidden
|
||||||
|
if (mapRef.current.getLayer('preview-line')) {
|
||||||
|
mapRef.current.setLayoutProperty('preview-line', 'visibility', 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset map pitch and zoom
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: [selectedPoint.lon, selectedPoint.lat],
|
||||||
|
zoom: 3,
|
||||||
|
pitch: 0,
|
||||||
|
bearing: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCityMarkers = () => {
|
||||||
|
if (cityMarkersRef.current) {
|
||||||
|
cityMarkersRef.current.forEach(marker => marker.remove());
|
||||||
|
cityMarkersRef.current = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCountryName = (code) => {
|
||||||
|
try {
|
||||||
|
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||||
|
return regionNames.of(code.toUpperCase()) || code;
|
||||||
|
} catch (e) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCityPopup = (city) => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
if (popupRef.current) popupRef.current.remove();
|
||||||
|
|
||||||
|
const popupNode = document.createElement('div');
|
||||||
|
popupNode.className = 'map-info-popup';
|
||||||
|
const countryName = getCountryName(city.country);
|
||||||
|
|
||||||
|
popupNode.innerHTML = `
|
||||||
|
<div class="popup-header">
|
||||||
|
<strong>${city.name}</strong>, ${countryName}
|
||||||
|
</div>
|
||||||
|
<div class="popup-body">
|
||||||
|
<p>Population: ${city.population.toLocaleString()}</p>
|
||||||
|
<p>Dist. from start: ${city.distance_km} km</p>
|
||||||
|
<p>Deviation: ${city.off_line_km} km</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
popupRef.current = new maplibregl.Popup({
|
||||||
|
closeButton: true,
|
||||||
|
closeOnClick: false,
|
||||||
|
maxWidth: '300px',
|
||||||
|
offset: [0, -20]
|
||||||
|
})
|
||||||
|
.setLngLat([city.lon, city.lat])
|
||||||
|
.setDOMContent(popupNode)
|
||||||
|
.addTo(mapRef.current);
|
||||||
|
|
||||||
|
popupRef.current.on('close', () => {
|
||||||
|
setSelectedCity(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startFlyOver = () => {
|
||||||
|
if (!lineOfSightData || !mapRef.current) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setFlightSpeed(1.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPlaying(true);
|
||||||
|
setSelectedCity(null);
|
||||||
|
if (popupRef.current) popupRef.current.remove();
|
||||||
|
|
||||||
|
const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]);
|
||||||
|
const route = turf.lineString(coordinates);
|
||||||
|
const routeDistance = turf.length(route);
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
let lastNearestCityId = null;
|
||||||
|
let lastFetchedDist = 0;
|
||||||
|
let currentProgress = 0;
|
||||||
|
let lastTimestamp = performance.now();
|
||||||
|
let currentSpeedMultiplier = 1.0;
|
||||||
|
let frameCount = 0;
|
||||||
|
|
||||||
|
async function animate(currentTime) {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
const deltaTime = currentTime - lastTimestamp;
|
||||||
|
lastTimestamp = currentTime;
|
||||||
|
|
||||||
|
const baseKmPerMs = 0.1; // 100km per second
|
||||||
|
|
||||||
|
const pathPoints = lineOfSightData.line_coordinates;
|
||||||
|
const pointIndex = Math.min(
|
||||||
|
Math.floor((currentProgress / routeDistance) * pathPoints.length),
|
||||||
|
pathPoints.length - 1
|
||||||
|
);
|
||||||
|
const isOverWater = pathPoints[pointIndex]?.is_over_water;
|
||||||
|
|
||||||
|
// Predictive land detection (look ahead 2000km)
|
||||||
|
let futureIsOverWater = true;
|
||||||
|
const lookAheadDistance = 2000;
|
||||||
|
const lookAheadSteps = 10;
|
||||||
|
for (let i = 1; i <= lookAheadSteps; i++) {
|
||||||
|
const checkDist = currentProgress + (lookAheadDistance * i) / lookAheadSteps;
|
||||||
|
const checkIndex = Math.min(
|
||||||
|
Math.floor((checkDist / routeDistance) * pathPoints.length),
|
||||||
|
pathPoints.length - 1
|
||||||
|
);
|
||||||
|
if (pathPoints[checkIndex] && !pathPoints[checkIndex].is_over_water) {
|
||||||
|
futureIsOverWater = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMultiplier = (isOverWater && futureIsOverWater) ? 20.0 : 1.0;
|
||||||
|
const acceleration = 0.005;
|
||||||
|
|
||||||
|
if (currentSpeedMultiplier < targetMultiplier) {
|
||||||
|
currentSpeedMultiplier = Math.min(currentSpeedMultiplier + acceleration * deltaTime, targetMultiplier);
|
||||||
|
} else if (currentSpeedMultiplier > targetMultiplier) {
|
||||||
|
currentSpeedMultiplier = Math.max(currentSpeedMultiplier - acceleration * deltaTime, targetMultiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
frameCount++;
|
||||||
|
if (frameCount % 10 === 0) {
|
||||||
|
setFlightSpeed(currentSpeedMultiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentProgress += baseKmPerMs * deltaTime * currentSpeedMultiplier;
|
||||||
|
|
||||||
|
const phase = Math.min(currentProgress / routeDistance, 1);
|
||||||
|
|
||||||
|
if (phase >= 1) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setFlightSpeed(1.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eye = turf.along(route, currentProgress).geometry.coordinates;
|
||||||
|
const target = turf.along(route, Math.min(currentProgress + 10, routeDistance)).geometry.coordinates;
|
||||||
|
|
||||||
|
const bearing = turf.bearing(turf.point(eye), turf.point(target));
|
||||||
|
|
||||||
|
mapRef.current.jumpTo({
|
||||||
|
center: eye,
|
||||||
|
zoom: 6,
|
||||||
|
pitch: 65,
|
||||||
|
bearing: bearing
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentProgress - lastFetchedDist > 2000) {
|
||||||
|
lastFetchedDist = currentProgress;
|
||||||
|
try {
|
||||||
|
const response = await apiService.getLineOfSight(
|
||||||
|
eye[1], eye[0], bearing, tolerance
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
const newCities = response.data.data.conurbations;
|
||||||
|
setLineOfSightData(prev => {
|
||||||
|
const existingIds = new Set(prev.conurbations.map(c => c.id));
|
||||||
|
const uniqueNewCities = newCities.filter(c => !existingIds.has(c.id));
|
||||||
|
const adjustedCities = uniqueNewCities.map(c => ({
|
||||||
|
...c,
|
||||||
|
distance_km: Math.round(c.distance_km + currentProgress)
|
||||||
|
}));
|
||||||
|
const updatedData = {
|
||||||
|
...prev,
|
||||||
|
conurbations: [...prev.conurbations, ...adjustedCities].sort((a, b) => a.distance_km - b.distance_km)
|
||||||
|
};
|
||||||
|
addMarkersToMap(adjustedCities, prev.conurbations.length);
|
||||||
|
return updatedData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching more cities during flight:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearestCity = lineOfSightData.conurbations.find(city =>
|
||||||
|
Math.abs(city.distance_km - currentProgress) < 100
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nearestCity && nearestCity.id !== lastNearestCityId) {
|
||||||
|
lastNearestCityId = nearestCity.id;
|
||||||
|
setSelectedCity(nearestCity);
|
||||||
|
showCityPopup(nearestCity);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMarkersToMap = (cities, startIndex = 0) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
cities.forEach((city, index) => {
|
||||||
|
const displayIndex = startIndex + index + 1;
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'city-marker';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="marker-number">${displayIndex}</div>
|
||||||
|
<div class="marker-dot"></div>
|
||||||
|
<div class="marker-label">${city.name}</div>
|
||||||
|
<div class="marker-pop">${(city.population / 1000).toFixed(0)}k</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const marker = new maplibregl.Marker(el)
|
||||||
|
.setLngLat([city.lon, city.lat])
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
marker.getElement().addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedCity(city);
|
||||||
|
showCityPopup(city);
|
||||||
|
});
|
||||||
|
|
||||||
|
cityMarkersRef.current.push(marker);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLineOnMap = (data) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
clearCityMarkers();
|
||||||
|
if (map.getSource('line-of-sight')) {
|
||||||
|
map.removeLayer('line-of-sight');
|
||||||
|
map.removeSource('line-of-sight');
|
||||||
|
}
|
||||||
|
|
||||||
|
map.addSource('line-of-sight', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: data.line_coordinates.map(coord => [coord.lon, coord.lat])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addMarkersToMap(data.conurbations);
|
||||||
|
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
data.line_coordinates.forEach(coord => {
|
||||||
|
bounds.extend([coord.lon, coord.lat]);
|
||||||
|
});
|
||||||
|
map.fitBounds(bounds, { padding: 50 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePreviewLine = (point, bearing) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.getSource('preview-line')) return;
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
const steps = 50;
|
||||||
|
const totalDistance = 20000;
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const dist = (totalDistance * i) / steps;
|
||||||
|
path.push(calculateDestination(point.lat, point.lon, bearing, dist));
|
||||||
|
}
|
||||||
|
|
||||||
|
map.getSource('preview-line').setData({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: path.map(p => [p.lon, p.lat])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.setStyle(getMapStyle(mapStyle));
|
||||||
|
}
|
||||||
|
}, [mapStyle]);
|
||||||
|
|
||||||
|
const getMapStyle = (style) => {
|
||||||
|
if (style === 'satellite') {
|
||||||
|
return {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
'esri-satellite': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'satellite-layer',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'esri-satellite',
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return style === 'dark'
|
||||||
|
? 'https://demotiles.maplibre.org/style.json'
|
||||||
|
: 'https://demotiles.maplibre.org/style.json';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowLineOfSight = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setSelectedCity(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);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-container">
|
||||||
|
<div className="map-container" ref={mapContainerRef}>
|
||||||
|
</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 === 'light' ? 'active-style' : ''}
|
||||||
|
onClick={() => setMapStyle('light')}
|
||||||
|
>Light</button>
|
||||||
|
<button
|
||||||
|
className={mapStyle === 'dark' ? 'active-style' : ''}
|
||||||
|
onClick={() => setMapStyle('dark')}
|
||||||
|
>Dark</button>
|
||||||
|
<button
|
||||||
|
className={mapStyle === 'satellite' ? 'active-style' : ''}
|
||||||
|
onClick={() => setMapStyle('satellite')}
|
||||||
|
>Satellite</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLocked ? (
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={handleShowLineOfSight}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={startFlyOver}
|
||||||
|
style={{ backgroundColor: isPlaying ? '#e74c3c' : '#3498db' }}
|
||||||
|
>
|
||||||
|
{isPlaying ? `⏹ Stop Flight (${flightSpeed.toFixed(1)}x)` : '✈️ Fly Over Route'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn-secondary"
|
||||||
|
onClick={handleStartAgain}
|
||||||
|
>
|
||||||
|
🔄 Start Again
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lineOfSightData && (
|
||||||
|
<div className="results-panel">
|
||||||
|
<h3>Conurbations Found ({lineOfSightData.conurbations.length})</h3>
|
||||||
|
<p className="hint">Click a city for details</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>City</th>
|
||||||
|
<th>Population</th>
|
||||||
|
<th>Dist.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lineOfSightData.conurbations.map((city, index) => (
|
||||||
|
<tr
|
||||||
|
key={city.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCity(city);
|
||||||
|
showCityPopup(city);
|
||||||
|
mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 });
|
||||||
|
}}
|
||||||
|
className={selectedCity?.id === city.id ? 'selected-row' : ''}
|
||||||
|
>
|
||||||
|
<td>{index + 1}</td>
|
||||||
|
<td>{city.name}</td>
|
||||||
|
<td>{(city.population / 1000).toFixed(0)}k</td>
|
||||||
|
<td>{city.distance_km}km</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</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;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios from 'axios';
|
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 || 'http://localhost:3001/api';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
// Global mocks if needed
|
||||||
+215
-5
@@ -3,6 +3,7 @@
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-container {
|
.map-container {
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
|
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group {
|
.control-group {
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
.setting-row span {
|
.setting-row span {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
|
min-width: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-row button {
|
.setting-row button {
|
||||||
@@ -78,6 +81,12 @@
|
|||||||
background: #e9ecef;
|
background: #e9ecef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-row button.active-style {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
border-color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -101,18 +110,66 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.results-panel {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
max-height: 450px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel h3 {
|
.results-panel h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
font-size: 16px;
|
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 {
|
.results-panel table {
|
||||||
@@ -124,21 +181,114 @@
|
|||||||
.results-panel th {
|
.results-panel th {
|
||||||
background: #34495e;
|
background: #34495e;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 10px;
|
padding: 8px 4px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel td {
|
.results-panel td {
|
||||||
padding: 8px;
|
padding: 8px 4px;
|
||||||
border-bottom: 1px solid #ddd;
|
border-bottom: 1px solid #ddd;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel tr:nth-child(even) {
|
.results-panel tr:nth-child(even) {
|
||||||
background: #f8f9fa;
|
background: #fdfdfd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-panel tr:hover {
|
.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 {
|
.more-info {
|
||||||
@@ -177,6 +327,25 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: sans-serif;
|
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 {
|
.marker-dot {
|
||||||
@@ -186,6 +355,7 @@
|
|||||||
border: 2px solid white;
|
border: 2px solid white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marker-label {
|
.marker-label {
|
||||||
@@ -209,6 +379,38 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-info-popup {
|
||||||
|
padding: 5px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body p {
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup {
|
||||||
|
z-index: 2000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3) !important;
|
||||||
|
padding: 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-container {
|
.app-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -223,4 +425,12 @@
|
|||||||
.map-container {
|
.map-container {
|
||||||
height: 60vh;
|
height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-panel-modal {
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: auto;
|
||||||
|
bottom: 20px;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'build',
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
esbuildOptions: {
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user