Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26f458df7 | ||
|
|
8d646ecd74 | ||
|
|
8890e64d0e | ||
|
|
411d10bbc6 | ||
|
|
76eed69c8f | ||
|
|
0bba5b1abe | ||
|
|
6a2f8856fe | ||
|
|
394394b387 | ||
|
|
eda4697b03 | ||
|
|
29048e9d2a | ||
|
|
0826c606ba | ||
|
|
e5413b4f0c | ||
|
|
b1dc09c714 | ||
|
|
62959398d5 | ||
|
|
b98bac9cff | ||
|
|
c83049aca5 | ||
|
|
234ed8ae94 | ||
|
|
bd5f5b98cc | ||
|
|
205e2d5be5 | ||
|
|
86aefba065 | ||
|
|
228032c913 | ||
|
|
cac76a2f69 | ||
|
|
bd9e92f304 | ||
|
|
4326109722 | ||
|
|
701904dca7 | ||
|
|
25f3a09374 | ||
|
|
f89bda232e | ||
|
|
259fdf1e10 | ||
|
|
8be3e04803 | ||
|
|
f0767c798c | ||
|
|
ad07a70125 | ||
|
|
e764efb189 | ||
|
|
af578aaff2 | ||
|
|
abae851315 |
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(npx playwright:*)",
|
||||||
|
"Bash(node -e ':*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 :3051, postgres)
|
||||||
|
docker-compose down # Stop all services
|
||||||
|
docker-compose logs -f # View logs
|
||||||
|
|
||||||
|
# Run tests inside containers
|
||||||
|
docker-compose exec backend npm test
|
||||||
|
docker-compose exec frontend npm test -- --run
|
||||||
|
docker-compose exec frontend npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local development (without Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && npm run dev # Nodemon dev server on :3051
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm run start # Vite dev server on :3050
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend unit tests (Jest + Supertest)
|
||||||
|
cd backend && npm test
|
||||||
|
|
||||||
|
# Frontend unit tests (Vitest)
|
||||||
|
cd frontend && npm test # Watch mode
|
||||||
|
cd frontend && npm test -- --run # Single run (CI mode)
|
||||||
|
|
||||||
|
# E2E tests (Playwright)
|
||||||
|
cd frontend && npm run test:e2e
|
||||||
|
|
||||||
|
# Seed the database with GeoNames city data
|
||||||
|
cd backend && npm run seed-data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build # Vite production build → frontend/build/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This is a monorepo with three services orchestrated by Docker Compose:
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/ → React 19 + Vite 6 SPA (MapLibre GL, Turf.js, Axios)
|
||||||
|
backend/ → Node 24 + Express 5 REST API
|
||||||
|
docker/ → PostgreSQL + PostGIS initialization
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
1. User clicks map → sets start point (lat/lon)
|
||||||
|
2. User selects direction (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,86 @@
|
|||||||
|
# GEMINI.md - Project Context: Line of Sight 🌍
|
||||||
|
|
||||||
|
## 🚀 Project Overview
|
||||||
|
**Line of Sight** is an interactive web application that visualizes great-circle paths around the Earth and identifies major conurbations (cities) along those paths based on user-defined direction and "fuzziness" tolerance.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Frontend**: React 19 single-page application using Vite 6 for build orchestration and MapLibre GL JS for vector map rendering.
|
||||||
|
- **Backend**: Node.js 24/Express 5 REST API.
|
||||||
|
- **Database**: PostgreSQL with the PostGIS extension for geospatial data storage and analysis. Uses `kartoza/postgis` for robust multi-arch (including ARM64) support.
|
||||||
|
- **Infrastructure**: Fully containerized using Docker and orchestrated with Docker Compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
- **Frontend**: `React 19`, `Vite 6`, `MapLibre GL JS`, `Axios`
|
||||||
|
- **Backend**: `Node.js 24`, `Express 5`, `node-postgres (pg) 8.20`
|
||||||
|
- **Database**: `PostgreSQL (kartoza/postgis)`, `PostGIS`
|
||||||
|
- **Testing**: `Vitest` (Frontend), `Jest` (Backend)
|
||||||
|
- **DevOps**: `Docker`, `Docker Compose`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚦 Getting Started & Key Commands
|
||||||
|
|
||||||
|
### Complete System (Docker)
|
||||||
|
| Action | Command |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Start Services** | `docker-compose up` |
|
||||||
|
| **Rebuild & Start** | `docker-compose up --build` |
|
||||||
|
| **Stop Services** | `docker-compose down` |
|
||||||
|
| **View Logs** | `docker-compose logs -f` |
|
||||||
|
| **Access DB (psql)** | `docker-compose exec postgres psql -U line_of_sight -d line_of_sight` |
|
||||||
|
|
||||||
|
### Backend Development (`/backend`)
|
||||||
|
| Action | Command |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Install** | `npm install` |
|
||||||
|
| **Start (Prod)** | `npm start` |
|
||||||
|
| **Start (Dev)** | `npm run dev` (Uses nodemon) |
|
||||||
|
| **Test** | `npm test` |
|
||||||
|
|
||||||
|
### Frontend Development (`/frontend`)
|
||||||
|
| Action | Command |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Install** | `npm install` |
|
||||||
|
| **Start (Dev)** | `npm run start` (Starts Vite) |
|
||||||
|
| **Build** | `npm run build` |
|
||||||
|
| **Test** | `npm run test` (Starts Vitest) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Key File Map
|
||||||
|
- `backend/app/server.js`: Main Express entry point and API route definitions.
|
||||||
|
- `frontend/src/App.js`: Primary React component containing map logic and state management.
|
||||||
|
- `frontend/src/services/api.js`: Axios-based API client for backend communication.
|
||||||
|
- `docker/init.sql`: Database schema definition including PostGIS extension and seed data.
|
||||||
|
- `docker-compose.yml`: Service orchestration for the entire stack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Development Conventions
|
||||||
|
- **Node Versioning**: Use `nvm` to manage Node.js. Run `nvm use` in the root directory to switch to Node 24 (specified in `.nvmrc`).
|
||||||
|
- **Geospatial Standards**: Latitude/Longitude are handled in decimal degrees. Geometry uses `SRID 4326` (WGS 84).
|
||||||
|
- **API Design**: RESTful endpoints returning JSON. The primary endpoint is `GET /api/line-of-sight`.
|
||||||
|
- **Styling**: Prefer custom CSS in `frontend/src/styles/` over utility-first frameworks like Tailwind for this specific project.
|
||||||
|
- **Testing**: Tests are located in `backend/tests/` and `frontend/src/__tests__/`. Use `vitest` (Frontend) and `jest` (Backend).
|
||||||
|
- **Environment**: Use `.env` files for configuration. `VITE_API_URL` is required for the frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Data Management
|
||||||
|
The project includes a compressed dataset of ~68,000 cities from Natural Earth.
|
||||||
|
|
||||||
|
| Action | Command |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Import/Refresh Data** | `./import-cities.sh` |
|
||||||
|
|
||||||
|
*Note: The script will truncate the `cities` table and perform a fresh import of the global dataset stored in `docker/data/cities.csv.gz`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Roadmap Highlights
|
||||||
|
- [ ] Implement real ST_DWithin() PostGIS queries in the backend.
|
||||||
|
- [ ] Import full Natural Earth/GeoNames datasets into the `cities` table.
|
||||||
|
- [ ] Transition from 2D MapLibre to a 3D globe visualization.
|
||||||
|
- [ ] Optimize line-of-sight calculations for long-distance paths.
|
||||||
@@ -51,8 +51,8 @@ docker-compose up --build
|
|||||||
|
|
||||||
### 3. Access Application
|
### 3. Access Application
|
||||||
|
|
||||||
- **Frontend**: http://localhost:3000
|
- **Frontend**: http://localhost:3050
|
||||||
- **Backend API**: http://localhost:3001
|
- **Backend API**: http://localhost:3051
|
||||||
- **Database**: localhost:5432 (PostgreSQL with PostGIS)
|
- **Database**: localhost:5432 (PostgreSQL with PostGIS)
|
||||||
|
|
||||||
## 🎮 How to Use
|
## 🎮 How to Use
|
||||||
@@ -219,8 +219,8 @@ npm run dev
|
|||||||
### Port Already in Use
|
### Port Already in Use
|
||||||
```bash
|
```bash
|
||||||
# Find process using port
|
# Find process using port
|
||||||
lsof -i :3000
|
lsof -i :3050
|
||||||
lsof -i :3001
|
lsof -i :3051
|
||||||
|
|
||||||
# Kill process
|
# Kill process
|
||||||
kill -9 <PID>
|
kill -9 <PID>
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ RUN npm install
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3001
|
EXPOSE 3051
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
+160
-47
@@ -1,73 +1,186 @@
|
|||||||
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 || 3051;
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight'
|
||||||
|
});
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Mock conurbation data for MVP
|
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||||
const MOCK_CONURBATIONS = [
|
const R = 6371;
|
||||||
{ id: 1, name: "London", population: 9000000, distance_km: 0, lat: 51.5074, lon: -0.1278 },
|
const brng = (bearing * Math.PI) / 180;
|
||||||
{ id: 2, name: "Paris", population: 2161000, distance_km: 344, lat: 48.8566, lon: 2.3522 },
|
const φ1 = (lat * Math.PI) / 180;
|
||||||
{ id: 3, name: "Berlin", population: 3644000, distance_km: 878, lat: 52.5200, lon: 13.4050 },
|
const λ1 = (lon * Math.PI) / 180;
|
||||||
{ id: 4, name: "Warsaw", population: 1793000, distance_km: 1200, lat: 52.2297, lon: 21.0122 },
|
const δ = distance / R;
|
||||||
{ id: 5, name: "Moscow", population: 12506000, distance_km: 2063, lat: 55.7558, lon: 37.6173 },
|
|
||||||
{ id: 6, name: "Kazan", population: 1257000, distance_km: 2850, lat: 55.7897, lon: 49.1219 },
|
|
||||||
{ id: 7, name: "Almaty", population: 2000000, distance_km: 3900, lat: 43.2220, lon: 76.8512 },
|
|
||||||
{ id: 8, name: "Urumqi", population: 3500000, distance_km: 4500, lat: 43.8256, lon: 87.6168 },
|
|
||||||
{ id: 9, name: "Lahore", population: 11126000, distance_km: 5400, lat: 31.5204, lon: 74.3587 },
|
|
||||||
{ id: 10, name: "New Delhi", population: 29399000, distance_km: 5800, lat: 28.6139, lon: 77.2090 },
|
|
||||||
{ id: 11, name: "Dhaka", population: 21006000, distance_km: 6200, lat: 23.8103, lon: 90.4125 },
|
|
||||||
{ id: 12, name: "Chennai", population: 10971000, distance_km: 6500, lat: 13.0827, lon: 80.2707 },
|
|
||||||
{ id: 13, name: "Bangkok", population: 10539000, distance_km: 7200, lat: 13.7563, lon: 100.5018 },
|
|
||||||
{ id: 14, name: "Jakarta", population: 10562000, distance_km: 8100, lat: -6.2088, lon: 106.8456 },
|
|
||||||
{ id: 15, name: "Singapore", population: 5686000, distance_km: 8300, lat: 1.3521, lon: 103.8198 },
|
|
||||||
{ id: 16, name: "Manila", population: 17801000, distance_km: 8700, lat: 14.5995, lon: 120.9842 },
|
|
||||||
{ id: 17, name: "Tokyo", population: 37400000, distance_km: 9500, lat: 35.6762, lon: 139.6503 },
|
|
||||||
{ id: 18, name: "Seoul", population: 9720000, distance_km: 9200, lat: 37.5665, lon: 126.9780 },
|
|
||||||
{ id: 19, name: "Beijing", population: 21540000, distance_km: 8900, lat: 39.9042, lon: 116.4074 },
|
|
||||||
{ id: 20, name: "Shanghai", population: 27058000, distance_km: 9000, lat: 31.2304, lon: 121.4737 }
|
|
||||||
];
|
|
||||||
|
|
||||||
// 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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const haversineKm = (lat1, lon1, lat2, lon2) => {
|
||||||
|
const R = 6371;
|
||||||
|
const toRad = (d) => d * Math.PI / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/line-of-sight', async (req, res) => {
|
||||||
const { lat, lon, direction, tolerance } = req.query;
|
const { lat, lon, direction, tolerance } = req.query;
|
||||||
|
|
||||||
console.log(`Received request: lat=${lat}, lon=${lon}, direction=${direction}, tolerance=${tolerance}`);
|
const startLat = parseFloat(lat) || 51.5074;
|
||||||
|
const startLon = parseFloat(lon) || -0.1278;
|
||||||
|
const bearing = parseInt(direction) || 0;
|
||||||
|
const toleranceKm = parseInt(tolerance) || 50;
|
||||||
|
|
||||||
|
console.log(`Processing request: lat=${startLat}, lon=${startLon}, bearing=${bearing}, tolerance=${toleranceKm}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pathPoints = [];
|
||||||
|
const totalDistance = 40074;
|
||||||
|
const steps = 160;
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const dist = (totalDistance * i) / steps;
|
||||||
|
pathPoints.push(calculateDestination(startLat, startLon, bearing, dist));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk land check: use a single query for all points to avoid connection pool exhaustion
|
||||||
|
const waterStart = Date.now();
|
||||||
|
const lons = pathPoints.map(p => p.lon);
|
||||||
|
const lats = pathPoints.map(p => p.lat);
|
||||||
|
|
||||||
|
const waterCheckQuery = `
|
||||||
|
WITH points AS (
|
||||||
|
SELECT i, ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography as p_geom
|
||||||
|
FROM unnest($1::float8[], $2::float8[]) WITH ORDINALITY AS t(lon, lat, i)
|
||||||
|
)
|
||||||
|
SELECT i, EXISTS (
|
||||||
|
SELECT 1 FROM cities
|
||||||
|
WHERE ST_DWithin(geom, p_geom, 500000)
|
||||||
|
LIMIT 1
|
||||||
|
) as has_land
|
||||||
|
FROM points
|
||||||
|
ORDER BY i;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const waterResults = await pool.query(waterCheckQuery, [lons, lats]);
|
||||||
|
const waterChecks = waterResults.rows.map(r => !r.has_land);
|
||||||
|
|
||||||
|
console.log(`Bulk water check completed in ${Date.now() - waterStart}ms for ${pathPoints.length} points.`);
|
||||||
|
|
||||||
|
const pathPointsWithWater = pathPoints.map((p, i) => ({
|
||||||
|
...p,
|
||||||
|
is_over_water: waterChecks[i]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lineWKT = `LINESTRING(${pathPoints.map(p => `${p.lon} ${p.lat}`).join(',')})`;
|
||||||
|
|
||||||
|
// Top 5 cities per 100 km bin, ranked by population descending
|
||||||
|
const query = `
|
||||||
|
WITH path AS (
|
||||||
|
SELECT ST_GeogFromText($1) as route,
|
||||||
|
ST_MakePoint($3, $4)::geography as start_node
|
||||||
|
),
|
||||||
|
candidates AS (
|
||||||
|
SELECT
|
||||||
|
id, name, population, country,
|
||||||
|
ST_Y(geom::geometry) as lat,
|
||||||
|
ST_X(geom::geometry) as lon,
|
||||||
|
ST_Distance(geom, (SELECT route FROM path)) / 1000 as distance_off_line_km,
|
||||||
|
ST_Distance(geom, (SELECT start_node FROM path)) / 1000 as distance_from_start_km,
|
||||||
|
ST_LineLocatePoint((SELECT route FROM path)::geography::geometry, geom::geometry) as pos_on_line,
|
||||||
|
FLOOR(ST_LineLocatePoint((SELECT route FROM path)::geography::geometry, geom::geometry) * 200)::int as bin_200km
|
||||||
|
FROM cities
|
||||||
|
WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000)
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY bin_200km ORDER BY population DESC NULLS LAST) as rank_in_bin
|
||||||
|
FROM candidates
|
||||||
|
)
|
||||||
|
SELECT id, name, population, country, lat, lon,
|
||||||
|
distance_off_line_km, distance_from_start_km, pos_on_line
|
||||||
|
FROM ranked
|
||||||
|
WHERE rank_in_bin <= 10
|
||||||
|
ORDER BY pos_on_line ASC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]);
|
||||||
|
const queryTime = Date.now() - startTime;
|
||||||
|
console.log(`Query completed in ${queryTime}ms. Found ${result.rows.length} candidates.`);
|
||||||
|
|
||||||
|
const startDedupe = Date.now();
|
||||||
|
// Greedy dynamic deduplication...
|
||||||
|
const byPopulation = [...result.rows].sort((a, b) => (b.population || 0) - (a.population || 0));
|
||||||
|
const accepted = [];
|
||||||
|
for (const city of byPopulation) {
|
||||||
|
const cityPop = city.population || 0;
|
||||||
|
const tooClose = accepted.some(s => {
|
||||||
|
const dist = haversineKm(s.lat, s.lon, city.lat, city.lon);
|
||||||
|
const sPop = s.population || 0;
|
||||||
|
if (cityPop > 1000000 && sPop > 1000000) return dist < 30;
|
||||||
|
if (cityPop > 100000 || sPop > 100000) return dist < 50;
|
||||||
|
return dist < 80;
|
||||||
|
});
|
||||||
|
if (!tooClose) accepted.push(city);
|
||||||
|
}
|
||||||
|
accepted.sort((a, b) => a.pos_on_line - b.pos_on_line);
|
||||||
|
console.log(`Deduplication completed in ${Date.now() - startDedupe}ms. Final count: ${accepted.length}`);
|
||||||
|
|
||||||
// Return mock data for MVP
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
start_point: { lat: parseFloat(lat) || 51.5074, lon: parseFloat(lon) || -0.1278 },
|
start_point: { lat: startLat, lon: startLon },
|
||||||
direction: parseInt(direction) || 45,
|
direction: bearing,
|
||||||
tolerance_km: parseInt(tolerance) || 50,
|
tolerance_km: toleranceKm,
|
||||||
conurbations: MOCK_CONURBATIONS.slice(0, 20),
|
conurbations: accepted.map(row => ({
|
||||||
line_coordinates: [
|
...row,
|
||||||
{ lat: 51.5074, lon: -0.1278 },
|
name: row.name || 'Unknown',
|
||||||
{ lat: 48.8566, lon: 2.3522 },
|
country: row.country || 'Unknown',
|
||||||
{ lat: 52.5200, lon: 13.4050 },
|
distance_km: Math.round(row.distance_from_start_km),
|
||||||
{ lat: 55.7558, lon: 37.6173 },
|
off_line_km: Math.round(row.distance_off_line_km)
|
||||||
{ lat: 43.2220, lon: 76.8512 },
|
})),
|
||||||
{ lat: 28.6139, lon: 77.2090 },
|
line_coordinates: pathPointsWithWater
|
||||||
{ lat: 13.7563, lon: 100.5018 },
|
}
|
||||||
{ lat: -6.2088, lon: 106.8456 },
|
|
||||||
{ lat: 35.6762, lon: 139.6503 },
|
|
||||||
{ lat: 51.5074, lon: -0.1278 } // Complete the circle
|
|
||||||
]
|
|
||||||
},
|
|
||||||
message: "Mock data returned for MVP - Real geospatial calculations coming soon"
|
|
||||||
});
|
});
|
||||||
|
console.log(`Request fully processed in ${Date.now() - startTime}ms`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Database query error:', err);
|
||||||
|
res.status(500).json({ success: false, error: 'Database query failed' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Line of Sight Backend running on port ${PORT}`);
|
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();
|
||||||
@@ -1,42 +1,74 @@
|
|||||||
/**
|
const request = require('supertest');
|
||||||
* Placeholder test file for Line of Sight Backend
|
const { app, calculateDestination } = require('../app/server');
|
||||||
*
|
|
||||||
* TODO: Add real tests for:
|
// Mock pg Pool
|
||||||
* - API endpoint validation
|
jest.mock('pg', () => {
|
||||||
* - Geospatial calculations
|
const mPool = {
|
||||||
* - Database queries
|
query: jest.fn(),
|
||||||
* - Error handling
|
on: jest.fn(),
|
||||||
*/
|
end: jest.fn(),
|
||||||
|
};
|
||||||
|
return { Pool: jest.fn(() => mPool) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const pool = new Pool();
|
||||||
|
|
||||||
describe('Line of Sight Backend', () => {
|
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', () => {
|
describe('GET /api/health', () => {
|
||||||
test('should return 200 OK', () => {
|
test('should return 200 OK', async () => {
|
||||||
// TODO: Implement health check test
|
const response = await request(app).get('/api/health');
|
||||||
expect(true).toBe(true);
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.status).toBe('ok');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/line-of-sight', () => {
|
describe('GET /api/line-of-sight', () => {
|
||||||
test('should return valid response structure', () => {
|
test('should return valid response structure', async () => {
|
||||||
// TODO: Implement line of sight API test
|
// Mock the water checks and the main city query
|
||||||
expect(true).toBe(true);
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ has_land: true }] }) // Simplified for tests, it actually calls many times
|
||||||
|
.mockResolvedValue({ rows: [{ id: 1, name: 'London', population: 9000000, country: 'GB', lat: 51.5, lon: -0.1, distance_off_line_km: 0, distance_from_start_km: 0, pos_on_line: 0 }] });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/line-of-sight')
|
||||||
|
.query({ lat: 51.5, lon: -0.1, direction: 45, tolerance: 50 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.data.conurbations).toBeDefined();
|
||||||
|
expect(response.body.data.line_coordinates).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle missing parameters', () => {
|
test('should handle database errors', async () => {
|
||||||
// TODO: Implement error handling test
|
pool.query.mockRejectedValue(new Error('DB Error'));
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Geospatial Calculations', () => {
|
const response = await request(app)
|
||||||
test('should calculate great circle path', () => {
|
.get('/api/line-of-sight')
|
||||||
// TODO: Implement geospatial calculation tests
|
.query({ lat: 51.5, lon: -0.1, direction: 45, tolerance: 50 });
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should filter cities within tolerance', () => {
|
expect(response.status).toBe(500);
|
||||||
// TODO: Implement tolerance filtering tests
|
expect(response.body.success).toBe(false);
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+11
-14
@@ -1,17 +1,13 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgis/postgis:15-3.3-alpine
|
image: kartoza/postgis:latest
|
||||||
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
|
||||||
ports:
|
|
||||||
- "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"]
|
||||||
@@ -24,11 +20,12 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: line-of-sight-backend
|
container_name: line-of-sight-backend
|
||||||
ports:
|
# Removed public port exposure for security; accessible via frontend proxy
|
||||||
- "3001:3001"
|
# ports:
|
||||||
|
# - "3051:3051"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight
|
- DATABASE_URL=postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight
|
||||||
- PORT=3001
|
- PORT=3051
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -42,9 +39,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=/api
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Binary file not shown.
+4
-4
@@ -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 (relative to the frontend host)
|
||||||
ENV REACT_APP_API_URL=http://localhost:3001/api
|
ENV VITE_API_URL=/api
|
||||||
|
|
||||||
# Start development server
|
# Start development server
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// MapLibre GL requires WebGL which isn't available in headless Chromium.
|
||||||
|
// Intercept the module request and return a lightweight mock so React can render.
|
||||||
|
const MAPLIBRE_MOCK = `
|
||||||
|
class Map {
|
||||||
|
constructor(opts) {
|
||||||
|
this._listeners = {};
|
||||||
|
Promise.resolve().then(() => this._emit('style.load'));
|
||||||
|
}
|
||||||
|
_emit(event) {
|
||||||
|
(this._listeners[event] || []).forEach(fn => fn());
|
||||||
|
}
|
||||||
|
on(event, fn) {
|
||||||
|
if (!this._listeners[event]) this._listeners[event] = [];
|
||||||
|
this._listeners[event].push(fn);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
off(event, fn) {
|
||||||
|
this._listeners[event] = (this._listeners[event] || []).filter(l => l !== fn);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
remove() {}
|
||||||
|
getSource() { return null; }
|
||||||
|
getLayer() { return null; }
|
||||||
|
addSource() {}
|
||||||
|
addLayer() {}
|
||||||
|
removeLayer() {}
|
||||||
|
removeSource() {}
|
||||||
|
setSky() {}
|
||||||
|
setTerrain() {}
|
||||||
|
setLayoutProperty() {}
|
||||||
|
flyTo() {}
|
||||||
|
jumpTo() {}
|
||||||
|
setStyle() { Promise.resolve().then(() => this._emit('style.load')); }
|
||||||
|
setProjection() {}
|
||||||
|
hasImage() { return false; }
|
||||||
|
addImage() {}
|
||||||
|
getCanvas() { return document.createElement('canvas'); }
|
||||||
|
project() { return { x: 0, y: 0 }; }
|
||||||
|
unproject() { return { lng: 0, lat: 0 }; }
|
||||||
|
}
|
||||||
|
class Marker {
|
||||||
|
constructor(opts) {
|
||||||
|
this._el = (opts && opts.element) || document.createElement('div');
|
||||||
|
}
|
||||||
|
setLngLat() { return this; }
|
||||||
|
addTo() { return this; }
|
||||||
|
remove() {}
|
||||||
|
getLngLat() { return { lng: 0, lat: 0 }; }
|
||||||
|
getElement() { return this._el; }
|
||||||
|
}
|
||||||
|
class Popup {
|
||||||
|
setLngLat() { return this; }
|
||||||
|
setDOMContent() { return this; }
|
||||||
|
addTo() { return this; }
|
||||||
|
remove() {}
|
||||||
|
}
|
||||||
|
class LngLatBounds {
|
||||||
|
extend() { return this; }
|
||||||
|
}
|
||||||
|
export default { Map, Marker, Popup, LngLatBounds };
|
||||||
|
`;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route('**maplibre-gl.js*', async route => {
|
||||||
|
await route.fulfill({ contentType: 'application/javascript', body: MAPLIBRE_MOCK });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Line of Sight Application', () => {
|
||||||
|
test('should load the home page and show settings', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('text=Line of Sight Settings')).toBeVisible();
|
||||||
|
await expect(page.locator('label:has-text("Direction")')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to toggle map style', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('h3:text("Line of Sight Settings")', { timeout: 10000 });
|
||||||
|
const darkButton = page.locator('button:text("Dark")');
|
||||||
|
await darkButton.click();
|
||||||
|
await expect(darkButton).toHaveClass(/active-style/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show results when clicking the search button', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('h3:text("Line of Sight Settings")', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.route('**/api/line-of-sight*', async route => {
|
||||||
|
const json = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
conurbations: [
|
||||||
|
{ id: 1, name: 'Test City', population: 100000, country: 'TS', lat: 0, lon: 0, distance_km: 10, off_line_km: 1 }
|
||||||
|
],
|
||||||
|
line_coordinates: [{ lat: 0, lon: 0 }, { lat: 1, lon: 1 }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await route.fulfill({ json });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.click('button:text("Show Line of Sight")');
|
||||||
|
await expect(page.locator('text=Conurbations Found')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Test City')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,5 +11,6 @@
|
|||||||
</head>
|
</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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html style='scrollbar-gutter: stable both-edges;'>
|
||||||
|
<head>
|
||||||
|
<meta charset='UTF-8'>
|
||||||
|
<meta name='color-scheme' content='dark light'>
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||||
|
<title>Playwright Test Report</title>
|
||||||
|
<script type="module">var NA=Object.defineProperty;var BA=(l,u,c)=>u in l?NA(l,u,{enumerable:!0,configurable:!0,writable:!0,value:c}):l[u]=c;var yn=(l,u,c)=>BA(l,typeof u!="symbol"?u+"":u,c);(function(){const u=document.createElement("link").relList;if(u&&u.supports&&u.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))f(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const h of o.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&f(h)}).observe(document,{childList:!0,subtree:!0});function c(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function f(r){if(r.ep)return;r.ep=!0;const o=c(r);fetch(r.href,o)}})();function UA(l){return l&&l.__esModule&&Object.prototype.hasOwnProperty.call(l,"default")?l.default:l}var Mf={exports:{}},Ei={};/**
|
||||||
|
* @license React
|
||||||
|
* react-jsx-runtime.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var D1;function QA(){if(D1)return Ei;D1=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.fragment");function c(f,r,o){var h=null;if(o!==void 0&&(h=""+o),r.key!==void 0&&(h=""+r.key),"key"in r){o={};for(var v in r)v!=="key"&&(o[v]=r[v])}else o=r;return r=o.ref,{$$typeof:l,type:f,key:h,ref:r!==void 0?r:null,props:o}}return Ei.Fragment=u,Ei.jsx=c,Ei.jsxs=c,Ei}var M1;function zA(){return M1||(M1=1,Mf.exports=QA()),Mf.exports}var m=zA();const YA=15,xt=0,bn=1,LA=2,ye=-2,Ut=-3,j1=-4,xn=-5,Me=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],L2=1440,GA=0,XA=4,VA=9,ZA=5,qA=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],IA=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],KA=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],kA=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],JA=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,Line truncated
|
||||||
|
* @license React
|
||||||
|
* react.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var T2;function B5(){if(T2)return dt;T2=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),o=Symbol.for("react.consumer"),h=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),y=Symbol.for("react.suspense"),A=Symbol.for("react.memo"),E=Symbol.for("react.lazy"),S=Symbol.for("react.activity"),O=Symbol.iterator;function X(C){return C===null||typeof C!="object"?null:(C=O&&C[O]||C["@@iterator"],typeof C=="function"?C:null)}var B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},b=Object.assign,p={};function x(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}x.prototype.isReactComponent={},x.prototype.setState=function(C,L){if(typeof C!="object"&&typeof C!="function"&&C!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,C,L,"setState")},x.prototype.forceUpdate=function(C){this.updater.enqueueForceUpdate(this,C,"forceUpdate")};function R(){}R.prototype=x.prototype;function U(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}var Z=U.prototype=new R;Z.constructor=U,b(Z,x.prototype),Z.isPureReactComponent=!0;var F=Array.isArray;function j(){}var D={H:null,A:null,T:null,S:null},N=Object.prototype.hasOwnProperty;function K(C,L,W){var et=W.ref;return{$$typeof:l,type:C,key:L,ref:et!==void 0?et:null,props:W}}function J(C,L){return K(C.type,L,C.props)}function k(C){return typeof C=="object"&&C!==null&&C.$$typeof===l}function nt(C){var L={"=":"=0",":":"=2"};return"$"+C.replace(/[=:]/g,function(W){return L[W]})}var P=/\/+/g;function st(C,L){return typeof C=="object"&&C!==null&&C.key!=null?nt(""+C.key):L.toString(36)}function it(C){switch(C.status){case"fulfilled":return C.value;case"rejected":throw C.reason;default:switch(typeof C.status=="string"?C.then(j,j):(C.status="pending",C.then(function(L){C.status==="pending"&&(C.status="fulfilled",C.value=L)},function(L){C.status==="pending"&&(C.status="rejected",C.reason=L)})),C.status){case"fulfilled":return C.value;case"rejected":throw C.reason}}throw C}function H(C,L,W,et,rt){var ot=typeof C;(ot==="undefined"||ot==="boolean")&&(C=null);var gt=!1;if(C===null)gt=!0;else switch(ot){case"bigint":case"string":case"number":gt=!0;break;case"object":switch(C.$$typeof){case l:case u:gt=!0;break;case E:return gt=C._init,H(gt(C._payload),L,W,et,rt)}}if(gt)return rt=rt(C),gt=et===""?"."+st(C,0):et,F(rt)?(W="",gt!=null&&(W=gt.replace(P,"$&/")+"/"),H(rt,L,W,"",function(On){return On})):rt!=null&&(k(rt)&&(rt=J(rt,W+(rt.key==null||C&&C.key===rt.key?"":(""+rt.key).replace(P,"$&/")+"/")+gt)),L.push(rt)),1;gt=0;var Ft=et===""?".":et+":";if(F(C))for(var Qt=0;Qt<C.length;Qt++)et=C[Qt],ot=Ft+st(et,Qt),gt+=H(et,L,W,ot,rt);else if(Qt=X(C),typeof Qt=="function")for(C=Qt.call(C),Qt=0;!(et=C.next()).done;)et=et.value,ot=Ft+st(et,Qt++),gt+=H(et,L,W,ot,rt);else if(ot==="object"){if(typeof C.then=="function")return H(it(C),L,W,et,rt);throw L=String(C),Error("Objects are not valid as a React child (found: "+(L==="[object Object]"?"object with keys {"+Object.keys(C).join(", ")+"}":L)+"). If you meant to render a collection of children, use an array instead.")}return gt}function _(C,L,W){if(C==null)return C;var et=[],rt=0;return H(C,et,"","",function(ot){return L.call(W,ot,rt++)}),et}function $(C){if(C._status===-1){var L=C._result;L=L(),L.then(function(W){(C._status===0||C._status===-1)&&(C._status=1,C._result=W)},function(W){(C._status===0||C._status===-1)&&(C._status=2,C._result=W)}),C._status===-1&&(C._status=0,C._result=L)}if(C._status===1)return C._result.default;throw C._result}var ht=typeof reportError=="function"?reportError:function(C){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var L=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof C=="object"&&C!==null&&typeof C.message=="string"?String(C.message):String(C),error:C});if(!window.dispatchEvent(L))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",C);return}console.error(C)},tt={map:_,forEach:function(C,L,W){_(C,function(){L.apply(this,arguments)},W)},count:function(C){var L=0;return _(C,function(){L++}),L},toArray:function(C){return _(C,function(L){return L})||[]},only:function(C){if(!k(C))throw Error("React.Children.only expected to receive a single React element child.");return C}};return dt.Activity=S,dt.Children=tt,dt.Component=x,dt.Fragment=c,dt.Profiler=r,dt.PureComponent=U,dt.StrictMode=f,dt.Suspense=y,dt.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=D,dt.__COMPILER_RUNTIME={__proto__:null,c:function(C){return D.H.useMemoCache(C)}},dt.cache=function(C){return function(){return C.apply(null,arguments)}},dt.cacheSignal=function(){return null},dt.cloneElement=function(C,L,W){if(C==nullLine truncated
|
||||||
|
* @license React
|
||||||
|
* scheduler.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var O2;function U5(){return O2||(O2=1,(function(l){function u(H,_){var $=H.length;H.push(_);t:for(;0<$;){var ht=$-1>>>1,tt=H[ht];if(0<r(tt,_))H[ht]=_,H[$]=tt,$=ht;else break t}}function c(H){return H.length===0?null:H[0]}function f(H){if(H.length===0)return null;var _=H[0],$=H.pop();if($!==_){H[0]=$;t:for(var ht=0,tt=H.length,C=tt>>>1;ht<C;){var L=2*(ht+1)-1,W=H[L],et=L+1,rt=H[et];if(0>r(W,$))et<tt&&0>r(rt,W)?(H[ht]=rt,H[et]=$,ht=et):(H[ht]=W,H[L]=$,ht=L);else if(et<tt&&0>r(rt,$))H[ht]=rt,H[et]=$,ht=et;else break t}}return _}function r(H,_){var $=H.sortIndex-_.sortIndex;return $!==0?$:H.id-_.id}if(l.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var o=performance;l.unstable_now=function(){return o.now()}}else{var h=Date,v=h.now();l.unstable_now=function(){return h.now()-v}}var y=[],A=[],E=1,S=null,O=3,X=!1,B=!1,b=!1,p=!1,x=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,U=typeof setImmediate<"u"?setImmediate:null;function Z(H){for(var _=c(A);_!==null;){if(_.callback===null)f(A);else if(_.startTime<=H)f(A),_.sortIndex=_.expirationTime,u(y,_);else break;_=c(A)}}function F(H){if(b=!1,Z(H),!B)if(c(y)!==null)B=!0,j||(j=!0,nt());else{var _=c(A);_!==null&&it(F,_.startTime-H)}}var j=!1,D=-1,N=5,K=-1;function J(){return p?!0:!(l.unstable_now()-K<N)}function k(){if(p=!1,j){var H=l.unstable_now();K=H;var _=!0;try{t:{B=!1,b&&(b=!1,R(D),D=-1),X=!0;var $=O;try{e:{for(Z(H),S=c(y);S!==null&&!(S.expirationTime>H&&J());){var ht=S.callback;if(typeof ht=="function"){S.callback=null,O=S.priorityLevel;var tt=ht(S.expirationTime<=H);if(H=l.unstable_now(),typeof tt=="function"){S.callback=tt,Z(H),_=!0;break e}S===c(y)&&f(y),Z(H)}else f(y);S=c(y)}if(S!==null)_=!0;else{var C=c(A);C!==null&&it(F,C.startTime-H),_=!1}}break t}finally{S=null,O=$,X=!1}_=void 0}}finally{_?nt():j=!1}}}var nt;if(typeof U=="function")nt=function(){U(k)};else if(typeof MessageChannel<"u"){var P=new MessageChannel,st=P.port2;P.port1.onmessage=k,nt=function(){st.postMessage(null)}}else nt=function(){x(k,0)};function it(H,_){D=x(function(){H(l.unstable_now())},_)}l.unstable_IdlePriority=5,l.unstable_ImmediatePriority=1,l.unstable_LowPriority=4,l.unstable_NormalPriority=3,l.unstable_Profiling=null,l.unstable_UserBlockingPriority=2,l.unstable_cancelCallback=function(H){H.callback=null},l.unstable_forceFrameRate=function(H){0>H||125<H?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):N=0<H?Math.floor(1e3/H):5},l.unstable_getCurrentPriorityLevel=function(){return O},l.unstable_next=function(H){switch(O){case 1:case 2:case 3:var _=3;break;default:_=O}var $=O;O=_;try{return H()}finally{O=$}},l.unstable_requestPaint=function(){p=!0},l.unstable_runWithPriority=function(H,_){switch(H){case 1:case 2:case 3:case 4:case 5:break;default:H=3}var $=O;O=H;try{return _()}finally{O=$}},l.unstable_scheduleCallback=function(H,_,$){var ht=l.unstable_now();switch(typeof $=="object"&&$!==null?($=$.delay,$=typeof $=="number"&&0<$?ht+$:ht):$=ht,H){case 1:var tt=-1;break;case 2:tt=250;break;case 5:tt=1073741823;break;case 4:tt=1e4;break;default:tt=5e3}return tt=$+tt,H={id:E++,callback:_,priorityLevel:H,startTime:$,expirationTime:tt,sortIndex:-1},$>ht?(H.sortIndex=$,u(A,H),c(y)===null&&H===c(A)&&(b?(R(D),D=-1):b=!0,it(F,$-ht))):(H.sortIndex=tt,u(y,H),B||X||(B=!0,j||(j=!0,nt()))),H},l.unstable_shouldYield=J,l.unstable_wrapCallback=function(H){var _=O;return function(){var $=O;O=_;try{return H.apply(this,arguments)}finally{O=$}}}})(qf)),qf}var w2;function Q5(){return w2||(w2=1,Zf.exports=U5()),Zf.exports}var If={exports:{}},ce={};/**
|
||||||
|
* @license React
|
||||||
|
* react-dom.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var R2;function z5(){if(R2)return ce;R2=1;var l=xr();function u(y){var A="https://react.dev/errors/"+y;if(1<arguments.length){A+="?args[]="+encodeURIComponent(arguments[1]);for(var E=2;E<arguments.length;E++)A+="&args[]="+encodeURIComponent(arguments[E])}return"Minified React error #"+y+"; visit "+A+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function c(){}var f={d:{f:c,r:function(){throw Error(u(522))},D:c,C:c,L:c,m:c,X:c,S:c,M:c},p:0,findDOMNode:null},r=Symbol.for("react.portal");function o(y,A,E){var S=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:r,key:S==null?null:""+S,children:y,containerInfo:A,implementation:E}}var h=l.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function v(y,A){if(y==="font")return"";if(typeof A=="string")return A==="use-credentials"?A:""}return ce.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ce.createPortal=function(y,A){var E=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!A||A.nodeType!==1&&A.nodeType!==9&&A.nodeType!==11)throw Error(u(299));return o(y,A,null,E)},ce.flushSync=function(y){var A=h.T,E=f.p;try{if(h.T=null,f.p=2,y)return y()}finally{h.T=A,f.p=E,f.d.f()}},ce.preconnect=function(y,A){typeof y=="string"&&(A?(A=A.crossOrigin,A=typeof A=="string"?A==="use-credentials"?A:"":void 0):A=null,f.d.C(y,A))},ce.prefetchDNS=function(y){typeof y=="string"&&f.d.D(y)},ce.preinit=function(y,A){if(typeof y=="string"&&A&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin),O=typeof A.integrity=="string"?A.integrity:void 0,X=typeof A.fetchPriority=="string"?A.fetchPriority:void 0;E==="style"?f.d.S(y,typeof A.precedence=="string"?A.precedence:void 0,{crossOrigin:S,integrity:O,fetchPriority:X}):E==="script"&&f.d.X(y,{crossOrigin:S,integrity:O,fetchPriority:X,nonce:typeof A.nonce=="string"?A.nonce:void 0})}},ce.preinitModule=function(y,A){if(typeof y=="string")if(typeof A=="object"&&A!==null){if(A.as==null||A.as==="script"){var E=v(A.as,A.crossOrigin);f.d.M(y,{crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0})}}else A==null&&f.d.M(y)},ce.preload=function(y,A){if(typeof y=="string"&&typeof A=="object"&&A!==null&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin);f.d.L(y,E,{crossOrigin:S,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0,type:typeof A.type=="string"?A.type:void 0,fetchPriority:typeof A.fetchPriority=="string"?A.fetchPriority:void 0,referrerPolicy:typeof A.referrerPolicy=="string"?A.referrerPolicy:void 0,imageSrcSet:typeof A.imageSrcSet=="string"?A.imageSrcSet:void 0,imageSizes:typeof A.imageSizes=="string"?A.imageSizes:void 0,media:typeof A.media=="string"?A.media:void 0})}},ce.preloadModule=function(y,A){if(typeof y=="string")if(A){var E=v(A.as,A.crossOrigin);f.d.m(y,{as:typeof A.as=="string"&&A.as!=="script"?A.as:void 0,crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0})}else f.d.m(y)},ce.requestFormReset=function(y){f.d.r(y)},ce.unstable_batchedUpdates=function(y,A){return y(A)},ce.useFormState=function(y,A,E){return h.H.useFormState(y,A,E)},ce.useFormStatus=function(){return h.H.useHostTransitionStatus()},ce.version="19.2.1",ce}var D2;function Y5(){if(D2)return If.exports;D2=1;function l(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(l)}catch(u){console.error(u)}}return l(),If.exports=z5(),If.exports}/**
|
||||||
|
* @license React
|
||||||
|
* react-dom-client.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var M2;function L5(){if(M2)return xi;M2=1;var l=Q5(),u=xr(),c=Y5();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)e+="&args[]="+encodeURIComponent(arguments[n])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function r(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function o(t){var e=t,n=t;if(t.alternate)for(;e.return;)e=e.return;else{t=e;do e=t,(e.flags&4098)!==0&&(n=e.return),t=e.return;while(t)}return e.tag===3?n:null}function h(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function v(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function y(t){if(o(t)!==t)throw Error(f(188))}function A(t){var e=t.alternate;if(!e){if(e=o(t),e===null)throw Error(f(188));return e!==t?null:t}for(var n=t,a=e;;){var i=n.return;if(i===null)break;var s=i.alternate;if(s===null){if(a=i.return,a!==null){n=a;continue}break}if(i.child===s.child){for(s=i.child;s;){if(s===n)return y(i),t;if(s===a)return y(i),e;s=s.sibling}throw Error(f(188))}if(n.return!==a.return)n=i,a=s;else{for(var d=!1,g=i.child;g;){if(g===n){d=!0,n=i,a=s;break}if(g===a){d=!0,a=i,n=s;break}g=g.sibling}if(!d){for(g=s.child;g;){if(g===n){d=!0,n=s,a=i;break}if(g===a){d=!0,a=s,n=i;break}g=g.sibling}if(!d)throw Error(f(189))}}if(n.alternate!==a)throw Error(f(190))}if(n.tag!==3)throw Error(f(188));return n.stateNode.current===n?t:e}function E(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=E(t),e!==null)return e;t=t.sibling}return null}var S=Object.assign,O=Symbol.for("react.element"),X=Symbol.for("react.transitional.element"),B=Symbol.for("react.portal"),b=Symbol.for("react.fragment"),p=Symbol.for("react.strict_mode"),x=Symbol.for("react.profiler"),R=Symbol.for("react.consumer"),U=Symbol.for("react.context"),Z=Symbol.for("react.forward_ref"),F=Symbol.for("react.suspense"),j=Symbol.for("react.suspense_list"),D=Symbol.for("react.memo"),N=Symbol.for("react.lazy"),K=Symbol.for("react.activity"),J=Symbol.for("react.memo_cache_sentinel"),k=Symbol.iterator;function nt(t){return t===null||typeof t!="object"?null:(t=k&&t[k]||t["@@iterator"],typeof t=="function"?t:null)}var P=Symbol.for("react.client.reference");function st(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===P?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case b:return"Fragment";case x:return"Profiler";case p:return"StrictMode";case F:return"Suspense";case j:return"SuspenseList";case K:return"Activity"}if(typeof t=="object")switch(t.$$typeof){case B:return"Portal";case U:return t.displayName||"Context";case R:return(t._context.displayName||"Context")+".Consumer";case Z:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case D:return e=t.displayName||null,e!==null?e:st(t.type)||"Memo";case N:e=t._payload,t=t._init;try{return st(t(e))}catch{}}return null}var it=Array.isArray,H=u.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,_=c.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,$={pending:!1,data:null,method:null,action:null},ht=[],tt=-1;function C(t){return{current:t}}function L(t){0>tt||(t.current=ht[tt],ht[tt]=null,tt--)}function W(t,e){tt++,ht[tt]=t.current,t.current=e}var et=C(null),rt=C(null),ot=C(null),gt=C(null);function Ft(t,e){switch(W(ot,e),W(rt,t),W(et,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?Pd(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=Pd(e),t=$d(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}L(et),W(et,t)}function Qt(){L(et),L(rt),L(ot)}function On(t){t.memoizedState!==null&&W(gt,t);var e=et.current,n=$d(e,t.type);e!==n&&(W(rt,t),W(et,n))}function ra(t){rt.current===t&&(L(et),L(rt)),gt.current===t&&(L(gt),gi._currentValue=$)}var wn,Ba;function Ee(t){if(wn===void 0)try{throw Error()}catch(n){var e=n.stack.trim().match(/\n( *(at )?)/);wn=e&&e[1]||"",Ba=-1<n.stack.indexOf(`
|
||||||
|
at`)?" (<anonymous>)":-1<n.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||||
|
`+wn+t+Ba}var Ua=!1;function Rn(t,e){if(!t||Ua)return"";Ua=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var I=function(){throw Error()};if(Object.defineProperty(I.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(I,[])}catch(G){var Y=G}Reflect.construct(t,[],I)}else{try{I.call()}catch(G){Y=G}t.call(I.prototype)}}else{try{throw Error()}catch(G){Y=G}(I=t())&&typeof I.catch=="function"&&I.catch(function(){})}}catch(G){if(G&&Y&&typeof G.stack=="string")return[G.stack,Y.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var i=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");i&&i.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var s=a.DetermineComponentFrameRoot(),d=s[0],g=s[1];if(d&&g){var T=d.split(`
|
||||||
|
`),z=g.split(`
|
||||||
|
`);for(i=a=0;a<T.length&&!T[a].includes("DetermineComponentFrameRoot");)a++;for(;i<z.length&&!z[i].includes("DetermineComponentFrameRoot");)i++;if(a===T.length||i===z.length)for(a=T.length-1,i=z.length-1;1<=a&&0<=i&&T[a]!==z[i];)i--;for(;1<=a&&0<=i;a--,i--)if(T[a]!==z[i]){if(a!==1||i!==1)do if(a--,i--,0>i||T[a]!==z[i]){var V=`
|
||||||
|
`+T[a].replace(" at new "," at ");return t.displayName&&V.includes("<anonymous>")&&(V=V.replace("<anonymous>",t.displayName)),V}while(1<=a&&0<=i);break}}}finally{Ua=!1,Error.prepareStackTrace=n}return(n=t?t.displayName||t.name:"")?Ee(n):""}function Qa(t,e){switch(t.tag){case 26:case 27:case 5:return Ee(t.type);case 16:return Ee("Lazy");case 13:return t.child!==e&&e!==null?Ee("Suspense Fallback"):Ee("Suspense");case 19:return Ee("SuspenseList");case 0:case 15:return Rn(t.type,!1);case 11:return Rn(t.type.render,!1);case 1:return Rn(t.type,!0);case 31:return Ee("Activity");default:return""}}function Bi(t){try{var e="",n=null;do e+=Qa(t,n),n=t,t=t.return;while(t);return e}catch(a){return`
|
||||||
|
Error generating stack: `+a.message+`
|
||||||
|
`+a.stack}}var mc=Object.prototype.hasOwnProperty,gc=l.unstable_scheduleCallback,Ac=l.unstable_cancelCallback,om=l.unstable_shouldYield,dm=l.unstable_requestPaint,pe=l.unstable_now,hm=l.unstable_getCurrentPriorityLevel,Dr=l.unstable_ImmediatePriority,Mr=l.unstable_UserBlockingPriority,Ui=l.unstable_NormalPriority,mm=l.unstable_LowPriority,jr=l.unstable_IdlePriority,gm=l.log,Am=l.unstable_setDisableYieldValue,wl=null,be=null;function Dn(t){if(typeof gm=="function"&&Am(t),be&&typeof be.setStrictMode=="function")try{be.setStrictMode(wl,t)}catch{}}var xe=Math.clz32?Math.clz32:Em,vm=Math.log,ym=Math.LN2;function Em(t){return t>>>=0,t===0?32:31-(vm(t)/ym|0)|0}var Qi=256,zi=262144,Yi=4194304;function oa(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Li(t,e,n){var a=t.pendingLanes;if(a===0)return 0;var i=0,s=t.suspendedLanes,d=t.pingedLanes;t=t.warmLanes;var g=a&134217727;return g!==0?(a=g&~s,a!==0?i=oa(a):(d&=g,d!==0?i=oa(d):n||(n=g&~t,n!==0&&(i=oa(n))))):(g=a&~s,g!==0?i=oa(g):d!==0?i=oa(d):n||(n=a&~t,n!==0&&(i=oa(n)))),i===0?0:e!==0&&e!==i&&(e&s)===0&&(s=i&-i,n=e&-e,s>=n||s===32&&(n&4194048)!==0)?e:i}function Rl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function pm(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hr(){var t=Yi;return Yi<<=1,(Yi&62914560)===0&&(Yi=4194304),t}function vc(t){for(var e=[],n=0;31>n;n++)e.push(t);return e}function Dl(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function bm(t,e,n,a,i,s){var d=t.pendingLanes;t.pendingLanes=n,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=n,t.entangledLanes&=n,t.errorRecoveryDisabledLanes&=n,t.shellSuspendCounter=0;var g=t.entanglements,T=t.expirationTimes,z=t.hiddenUpdates;for(n=d&~n;0<n;){var V=31-xe(n),I=1<<V;g[V]=0,T[V]=-1;var Y=z[V];if(Y!==null)for(z[V]=null,V=0;V<Y.length;V++){var G=Y[V];G!==null&&(G.lane&=-536870913)}n&=~I}a!==0&&Nr(t,a,0),s!==0&&i===0&&t.tag!==0&&(t.suspendedLanes|=s&~(d&~e))}function Nr(t,e,n){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-xe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|n&261930}function Br(t,e){var n=t.entangledLanes|=e;for(t=t.entanglements;n;){var a=31-xe(n),i=1<<a;i&e|t[a]&e&&(t[a]|=e),n&=~i}}function Ur(t,e){var n=e&-e;return n=(n&42)!==0?1:yc(n),(n&(t.suspendedLanes|e))!==0?0:n}function yc(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Ec(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function Qr(){var t=_.p;return t!==0?t:(t=window.event,t===void 0?32:x1(t.type))}function zr(t,e){var n=_.p;try{return _.p=t,e()}finally{_.p=n}}var Mn=Math.random().toString(36).slice(2),te="__reactFiber$"+Mn,oe="__reactProps$"+Mn,za="__reactContainer$"+Mn,pc="__reactEvents$"+Mn,xm="__reactListeners$"+Mn,Sm="__reactHandles$"+Mn,Yr="__reactResources$"+Mn,Ml="__reactMarker$"+Mn;function bc(t){delete t[te],delete t[oe],delete t[pc],delete t[xm],delete t[Sm]}function Ya(t){var e=t[te];if(e)return e;for(var n=t.parentNode;n;){if(e=n[za]||n[te]){if(n=e.alternate,e.child!==null||n!==null&&n.child!==null)for(t=u1(t);t!==null;){if(n=t[te])return n;t=u1(t)}return e}t=n,n=t.parentNode}return null}function La(t){if(t=t[te]||t[za]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function jl(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Ga(t){var e=t[Yr];return e||(e=t[Yr]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function Wt(t){t[Ml]=!0}var Lr=new Set,Gr={};function da(t,e){Xa(t,e),Xa(t+"Capture",e)}function Xa(t,e){for(Gr[t]=e,t=0;t<e.length;t++)Lr.add(e[t])}var Tm=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21Line truncated
|
||||||
|
`).replace(eA,"")}function Wd(t,e){return e=Fd(e),Fd(t)===e}function Dt(t,e,n,a,i,s){switch(n){case"children":typeof a=="string"?e==="body"||e==="textarea"&&a===""||Za(t,a):(typeof a=="number"||typeof a=="bigint")&&e!=="body"&&Za(t,""+a);break;case"className":Xi(t,"class",a);break;case"tabIndex":Xi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Xi(t,n,a);break;case"style":Fr(t,a,s);break;case"data":if(e!=="object"){Xi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||n!=="href")){t.removeAttribute(n);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(n,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof s=="function"&&(n==="formAction"?(e!=="input"&&Dt(t,e,"name",i.name,i,null),Dt(t,e,"formEncType",i.formEncType,i,null),Dt(t,e,"formMethod",i.formMethod,i,null),Dt(t,e,"formTarget",i.formTarget,i,null)):(Dt(t,e,"encType",i.encType,i,null),Dt(t,e,"method",i.method,i,null),Dt(t,e,"target",i.target,i,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"onClick":a!=null&&(t.onclick=tn);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}n=Zi(""+a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",n);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""+a):t.removeAttribute(n);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""):t.removeAttribute(n);break;case"capture":case"download":a===!0?t.setAttribute(n,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,a):t.removeAttribute(n);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(n,a):t.removeAttribute(n);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(n):t.setAttribute(n,a);break;case"popover":yt("beforetoggle",t),yt("toggle",t),Gi(t,"popover",a);break;case"xlinkActuate":$e(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":$e(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":$e(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":$e(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":$e(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":$e(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":$e(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":$e(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":$e(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Gi(t,"is",a);break;case"innerText":case"textContent":break;default:(!(2<n.length)||n[0]!=="o"&&n[0]!=="O"||n[1]!=="n"&&n[1]!=="N")&&(n=Dm.get(n)||n,Gi(t,n,a))}}function df(t,e,n,a,i,s){switch(n){case"style":Fr(t,a,s);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"children":typeof a=="string"?Za(t,a):(typeof a=="number"||typeof a=="bigint")&&Za(t,""+a);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"onClick":a!=null&&(t.onclick=tn);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inneLine truncated
|
||||||
|
linear-gradient(-45deg, #80808020 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #80808020 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #80808020 75%)`,backgroundSize:"20px 20px",backgroundPosition:"0 0, 0 10px, 10px -10px, -10px 0px",boxShadow:`rgb(0 0 0 / 10%) 0px 1.8px 1.9px,
|
||||||
|
rgb(0 0 0 / 15%) 0px 6.1px 6.3px,
|
||||||
|
rgb(0 0 0 / 10%) 0px -2px 4px,
|
||||||
|
rgb(0 0 0 / 15%) 0px -6.1px 12px,
|
||||||
|
rgb(0 0 0 / 25%) 0px 6px 12px`},um=({diff:l,noTargetBlank:u,hideDetails:c})=>{const[f,r]=ct.useState(l.diff?"diff":"actual"),[o,h]=ct.useState(!1),[v,y]=ct.useState(null),[A,E]=ct.useState("Expected"),[S,O]=ct.useState(null),[X,B]=ct.useState(null),[b,p]=Wh();ct.useEffect(()=>{(async()=>{var K,J,k,nt;y(await kf((K=l.expected)==null?void 0:K.attachment.path)),E(((J=l.expected)==null?void 0:J.title)||"Expected"),O(await kf((k=l.actual)==null?void 0:k.attachment.path)),B(await kf((nt=l.diff)==null?void 0:nt.attachment.path))})()},[l]);const x=v&&S&&X,R=x?Math.max(v.naturalWidth,S.naturalWidth,200):500,U=x?Math.max(v.naturalHeight,S.naturalHeight,200):500,Z=Math.min(1,(b.width-30)/R),F=Math.min(1,(b.width-50)/R/2),j=R*Z,D=U*Z,N={flex:"none",margin:"0 10px",cursor:"pointer",userSelect:"none"};return m.jsx("div",{"data-testid":"test-result-image-mismatch",style:{display:"flex",flexDirection:"column",alignItems:"center",flex:"auto"},ref:p,children:x&&m.jsxs(m.Fragment,{children:[m.jsxs("div",{"data-testid":"test-result-image-mismatch-tabs",style:{display:"flex",margin:"10px 0 20px"},children:[l.diff&&m.jsx("div",{style:{...N,fontWeight:f==="diff"?600:"initial"},onClick:()=>r("diff"),children:"Diff"}),m.jsx("div",{style:{...N,fontWeight:f==="actual"?600:"initial"},onClick:()=>r("actual"),children:"Actual"}),m.jsx("div",{style:{...N,fontWeight:f==="expected"?600:"initial"},onClick:()=>r("expected"),children:A}),m.jsx("div",{style:{...N,fontWeight:f==="sxs"?600:"initial"},onClick:()=>r("sxs"),children:"Side by side"}),m.jsx("div",{style:{...N,fontWeight:f==="slider"?600:"initial"},onClick:()=>r("slider"),children:"Slider"})]}),m.jsxs("div",{style:{display:"flex",justifyContent:"center",flex:"auto",minHeight:D+60},children:[l.diff&&f==="diff"&&m.jsx(En,{image:X,alt:"Diff",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="actual"&&m.jsx(En,{image:S,alt:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="expected"&&m.jsx(En,{image:v,alt:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="slider"&&m.jsx(Dv,{expectedImage:v,actualImage:S,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z,expectedTitle:A}),l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:o?X:S,title:o?"Diff":"Actual",onClick:()=>h(!o),hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F})]}),!l.diff&&f==="actual"&&m.jsx(En,{image:S,title:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="expected"&&m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:S,title:"Actual",canvasWidth:F*R,canvasHeight:F*U,scale:F})]})]}),!c&&m.jsxs("div",{style:{alignSelf:"start",lineHeight:"18px",marginLeft:"15px"},children:[m.jsx("div",{children:l.diff&&m.jsx("a",{target:"_blank",href:l.diff.attachment.path,rel:"noreferrer",children:l.diff.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.actual.attachment.path,rel:"noreferrer",children:l.actual.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.expected.attachment.path,rel:"noreferrer",children:l.expected.attachment.name})})]})]})})},Dv=({expectedImage:l,actualImage:u,canvasWidth:c,canvasHeight:f,scale:r,expectedTitle:o,hideSize:h})=>{const v={position:"absolute",top:0,left:0},[y,A]=ct.useState(c/2),E=l.naturalWidth===u.naturalWidth&&l.naturalHeight===u.naturalHeight;return m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column",userSelect:"none"},children:[!h&&m.jsxs("div",{style:{margin:5},children:[!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"Expected "}),m.jsx("span",{children:l.naturalWidth}),m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),m.jsx("span",{children:l.naturalHeight}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px 0 15px"},children:"Actual "}),!E&&m.jsx("span",{children:u.naturalWidth}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),!E&&m.jsx("span",{children:u.naturalHeight})]}),m.jsxs("div",{style:{position:"relative",width:c,height:f,margin:15,...fr},children:[m.jsx(Rv,{orientation:"horizontal",offsets:[y],setOffsets:S=>A(S[0]),resizerColor:"#57606a80",resizerWidth:6}),m.jsx("img",{alt:o,style:{width:l.naturalWidth*r,height:l.naturalHeight*r},draggable:"false",src:l.src}),m.jsx("div",{style:{...v,bottom:0,overflow:"hidden",width:y,...fr},children:m.jsx("img",{alt:"Actual",style:{width:u.naturalWidth*r,height:u.naturalHeight*r},draggable:"false",src:u.src})})]})]})},En=({image:l,title:u,alt:c,hideSize:f,canvasWidth:r,canvasHeight:o,scale:h,onClick:v})=>m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column"},children:[!f&&m.jsxs("div",{style:{margin:5},childrenLine truncated
|
||||||
|
# Instructions
|
||||||
|
|
||||||
|
- Following Playwright test failed.
|
||||||
|
- Explain why, be concise, respect Playwright best practices.
|
||||||
|
- Provide a snippet of code with the fix, if possible.
|
||||||
|
`.trimStart();async function zv({testInfo:l,metadata:u,errorContext:c,errors:f,buildCodeFrame:r,stdout:o,stderr:h}){var S;const v=new Set(f.filter(O=>O.message&&!O.message.includes(`
|
||||||
|
`)).map(O=>O.message));for(const O of f)for(const X of v.keys())(S=O.message)!=null&&S.includes(X)&&v.delete(X);const y=f.filter(O=>!(!O.message||!O.message.includes(`
|
||||||
|
`)&&!v.has(O.message)));if(!y.length)return;const A=[Qv,"# Test info","",l];o&&A.push("","# Stdout","","```",Jf(o),"```"),h&&A.push("","# Stderr","","```",Jf(h),"```"),A.push("","# Error details");for(const O of y)A.push("","```",Jf(O.message||""),"```");c&&A.push(c);const E=await r(y[y.length-1]);return E&&A.push("","# Test source","","```ts",E,"```"),u!=null&&u.gitDiff&&A.push("","# Local changes","","```diff",u.gitDiff,"```"),A.join(`
|
||||||
|
`)}const Yv=new RegExp("([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))","g");function Jf(l){return l.replace(Yv,"")}function Lv(l,u){var f;const c=new Map;for(const r of l){const o=r.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);if(!o)continue;const[,h,v,y=""]=o,A=h+y;let E=c.get(A);E||(E={name:A,anchors:[`attachment-${h}`]},c.set(A,E)),E.anchors.push(`attachment-${u.attachments.indexOf(r)}`),v==="actual"&&(E.actual={attachment:r}),v==="expected"&&(E.expected={attachment:r,title:"Expected"}),v==="previous"&&(E.expected={attachment:r,title:"Previous"}),v==="diff"&&(E.diff={attachment:r})}for(const[r,o]of c)!o.actual||!o.expected?c.delete(r):(l.delete(o.actual.attachment),l.delete(o.expected.attachment),l.delete((f=o.diff)==null?void 0:f.attachment));return[...c.values()]}const Gv=({test:l,result:u,testRunMetadata:c,options:f})=>{const{screenshots:r,videos:o,traces:h,otherAttachments:v,diffs:y,errors:A,otherAttachmentAnchors:E,screenshotAnchors:S,errorContext:O}=ct.useMemo(()=>{const B=u.attachments.filter(N=>!N.name.startsWith("_")),b=new Set(B.filter(N=>N.contentType.startsWith("image/"))),p=[...b].map(N=>`attachment-${B.indexOf(N)}`),x=B.filter(N=>N.contentType.startsWith("video/")),R=B.filter(N=>N.name==="trace"),U=B.find(N=>N.name==="error-context"),Z=new Set(B);[...b,...x,...R].forEach(N=>Z.delete(N));const F=[...Z].map(N=>`attachment-${B.indexOf(N)}`),j=Lv(b,u),D=u.errors.map(N=>N.message);return{screenshots:[...b],videos:x,traces:R,otherAttachments:Z,diffs:j,errors:D,otherAttachmentAnchors:F,screenshotAnchors:p,errorContext:U}},[u]),X=P5(async()=>{if(f!=null&&f.noCopyPrompt)return;const B=u.attachments.find(R=>R.name==="stdout"),b=u.attachments.find(R=>R.name==="stderr"),p=B!=null&&B.body&&B.contentType==="text/plain"?B.body:void 0,x=b!=null&&b.body&&b.contentType==="text/plain"?b.body:void 0;return await zv({testInfo:[`- Name: ${l.path.join(" >> ")} >> ${l.title}`,`- Location: ${l.location.file}:${l.location.line}:${l.location.column}`].join(`
|
||||||
|
`),metadata:c,errorContext:O!=null&&O.path?await fetch(O.path).then(R=>R.text()):O==null?void 0:O.body,errors:u.errors,buildCodeFrame:async R=>R.codeframe,stdout:p,stderr:x})},[l,O,c,u],void 0);return m.jsxs("div",{className:"test-result",children:[!!A.length&&m.jsxs(ke,{header:"Errors",children:[X&&m.jsx("div",{style:{position:"absolute",right:"16px",padding:"10px",zIndex:1},children:m.jsx(Nv,{prompt:X})}),A.map((B,b)=>{const p=Xv(B,y);return m.jsxs(m.Fragment,{children:[m.jsx(wr,{code:B},"test-result-error-message-"+b),p&&m.jsx(Bv,{diff:p})]})})]}),!!u.steps.length&&m.jsx(ke,{header:"Test Steps",children:u.steps.map((B,b)=>m.jsx(cm,{step:B,result:u,test:l,depth:0},`step-${b}`))}),y.map((B,b)=>m.jsx(Si,{id:B.anchors,children:m.jsx(ke,{dataTestId:"test-results-image-diff",header:`Image mismatch: ${B.name}`,revealOnAnchorId:B.anchors,children:m.jsx(um,{diff:B})})},`diff-${b}`)),!!r.length&&m.jsx(ke,{header:"Screenshots",revealOnAnchorId:S,children:r.map((B,b)=>m.jsxs(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:[m.jsx("a",{href:Ve(B.path),children:m.jsx("img",{className:"screenshot",src:Ve(B.path)})}),m.jsx(nc,{attachment:B,result:u})]},`screenshot-${b}`))}),!!h.length&&m.jsx(Si,{id:"attachment-trace",children:m.jsx(ke,{header:"Traces",revealOnAnchorId:"attachment-trace",children:m.jsxs("div",{children:[m.jsx("a",{href:Ve(nm(h)),children:m.jsx("img",{className:"screenshot",src:Cv,style:{width:192,height:117,marginLeft:20}})}),h.map((B,b)=>m.jsx(nc,{attachment:B,result:u,linkName:h.length===1?"trace":`trace-${b+1}`},`trace-${b}`))]})})}),!!o.length&&m.jsx(Si,{id:"attachment-video",children:m.jsx(ke,{header:"Videos",revealOnAnchorId:"attachment-video",children:o.map(B=>m.jsxs("div",{children:[m.jsx("video",{controls:!0,children:m.jsx("source",{src:Ve(B.path),type:B.contentType})}),m.jsx(nc,{attachment:B,result:u})]},B.path))})}),!!v.size&&m.jsx(ke,{header:"Attachments",revealOnAnchorId:E,dataTestId:"attachments",children:[...v].map((B,b)=>m.jsx(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:m.jsx(nc,{attachment:B,result:u,openInNewTab:B.contentType.startsWith("text/html")})},`attachment-link-${b}`))})]})};function Xv(l,u){const c=l.split(`
|
||||||
|
`)[0];if(!(!c.includes("toHaveScreenshot")&&!c.includes("toMatchSnapshot")))return u.find(f=>l.includes(f.name))}const cm=({test:l,step:u,result:c,depth:f})=>{const r=se();return m.jsx(Tv,{title:m.jsxs("div",{"aria-label":u.title,className:"step-title-container",children:[hc(u.error||u.duration===-1?"failed":u.skipped?"skipped":"passed"),m.jsxs("span",{className:"step-title-text",children:[m.jsx("span",{children:u.title}),u.count>1&&m.jsxs(m.Fragment,{children:[" ✕ ",m.jsx("span",{className:"test-result-counter",children:u.count})]}),u.location&&m.jsxs("span",{className:"test-result-path",children:["— ",u.location.file,":",u.location.line]})]}),m.jsx("span",{className:"step-spacer"}),u.attachments.length>0&&m.jsx("a",{className:"step-attachment-link",title:"reveal attachment",href:Ve(Cn({test:l,result:c,anchor:`attachment-${u.attachments[0]}`},r)),onClick:o=>{o.stopPropagation()},children:Ih()}),m.jsx("span",{className:"step-duration",children:Ol(u.duration)})]}),loadChildren:u.steps.length||u.snippet?()=>{const o=u.snippet?[m.jsx(wr,{testId:"test-snippet",code:u.snippet},"line")]:[],h=u.steps.map((v,y)=>m.jsx(cm,{step:v,depth:f+1,result:c,test:l},y));return o.concat(h)}:void 0,depth:f})},Vv=({projectNames:l,test:u,testRunMetadata:c,run:f,next:r,prev:o,options:h})=>{const[v,y]=ct.useState(f),A=se(),E=u.annotations.filter(S=>!S.type.startsWith("_"))??[];return m.jsxs(m.Fragment,{children:[m.jsx(Or,{title:u.title,leftSuperHeader:m.jsx("div",{className:"test-case-path",children:u.path.join(" › ")}),rightSuperHeader:m.jsxs(m.Fragment,{children:[m.jsx("div",{className:Ze(!o&&"hidden"),children:m.jsx(Tn,{href:Cn({test:o},A),children:"« previous"})}),m.jsx("div",{style:{width:10}}),m.jsx("div",{className:Ze(!r&&"hidden"),children:m.jsx(Tn,{href:Cn({test:r},A),children:"next »"})})]})}),m.jsxs("div",{className:"hbox",style:{lineHeight:"24px"},children:[m.jsx("div",{className:"test-case-location",children:m.jsxs(Sr,{value:`${u.location.file}:${u.location.line}`,children:[u.location.file,":",u.location.line]})}),m.jsx("div",{style:{flex:"auto"}}),m.jsx(tm,{test:u,trailingSeparator:!0}),m.jsx("div",{className:"test-case-duration",children:Ol(u.duration)})]}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:l,activeProjectName:u.projectName,otherLabels:u.tags}),u.results.length===0&&E.length!==0&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:E.map((S,O)=>m.jsx(z2,{annotation:S},O))}),m.jsx(Sv,{tabs:u.results.map((S,O)=>({id:String(O),title:m.jsxs("div",{style:{display:"flex",alignItems:"center"},children:[hc(S.status)," ",Zv(O),u.results.length>1&&m.jsx("span",{className:"test-case-run-duration",children:Ol(S.duration)})]}),render:()=>{const X=S.annotations.filter(B=>!B.type.startsWith("_"));return m.jsxs(m.Fragment,{children:[!!X.length&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:X.map((B,b)=>m.jsx(z2,{annotation:B},b))}),m.jsx(Gv,{test:u,result:S,testRunMetadata:c,options:h})]})}}))||[],selectedTab:String(v),setSelectedTab:S=>y(+S)})]})};function z2({annotation:{type:l,description:u}}){return m.jsxs("div",{className:"test-case-annotation",children:[m.jsx("span",{style:{fontWeight:"bold"},children:l}),u&&m.jsxs(Sr,{value:u,children:[": ",Di(u)]})]})}function Zv(l){return l?`Retry #${l}`:"Run"}const sm=({file:l,projectNames:u,isFileExpanded:c,setFileExpanded:f,footer:r})=>{const o=se();return m.jsx(im,{expanded:c?c(l.fileId):void 0,noInsets:!0,setExpanded:f?(h=>f(l.fileId,h)):void 0,header:m.jsx("span",{className:"chip-header-allow-selection",children:l.fileName}),footer:r,children:l.tests.map(h=>m.jsxs("div",{className:Ze("test-file-test","test-file-test-outcome-"+h.outcome),children:[m.jsxs("div",{className:"hbox",style:{alignItems:"flex-start"},children:[m.jsxs("div",{className:"hbox",children:[m.jsx("span",{className:"test-file-test-status-icon",children:hc(h.outcome)}),m.jsxs("span",{children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),children:m.jsx("span",{className:"test-file-title",children:[...h.path,h.title].join(" › ")})}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:u,activeProjectName:h.projectName,otherLabels:h.tags})]})]}),m.jsx("span",{"data-testid":"test-duration",style:{minWidth:"50px",textAlign:"right"},children:Ol(h.duration)})]}),m.jsx("div",{className:"test-file-details-row",children:m.jsxs("div",{className:"test-file-details-row-items",children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),className:"test-file-path-link",children:m.jsxs("span",{className:"test-file-path",children:[h.location.file,":",h.location.line]})}),m.jsx(qv,{test:h}),m.jsx(Iv,{test:h}),m.jsx(tm,{test:h,dim:!0})]})})]},`test-${h.testId}`))})};function qv({test:l}){const u=se();for(const c of l.results)for(const f of c.attachments)if(f.contentType.startsWith("image/")&&f.name.match(/-(expected|actual|diff)/))return m.jsx(Tr,{href:Cn({test:l,result:c,anchor:`attachment-${c.attachments.indexOf(f)}`},u),Line truncated
|
||||||
|
</script>
|
||||||
|
<style type='text/css'>:root{--color-canvas-default-transparent: rgba(255,255,255,0);--color-marketing-icon-primary: #218bff;--color-marketing-icon-secondary: #54aeff;--color-diff-blob-addition-num-text: #24292f;--color-diff-blob-addition-fg: #24292f;--color-diff-blob-addition-num-bg: #CCFFD8;--color-diff-blob-addition-line-bg: #E6FFEC;--color-diff-blob-addition-word-bg: #ABF2BC;--color-diff-blob-deletion-num-text: #24292f;--color-diff-blob-deletion-fg: #24292f;--color-diff-blob-deletion-num-bg: #FFD7D5;--color-diff-blob-deletion-line-bg: #FFEBE9;--color-diff-blob-deletion-word-bg: rgba(255,129,130,.4);--color-diff-blob-hunk-num-bg: rgba(84,174,255,.4);--color-diff-blob-expander-icon: #57606a;--color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;--color-diffstat-deletion-border: rgba(27,31,36,.15);--color-diffstat-addition-border: rgba(27,31,36,.15);--color-diffstat-addition-bg: #2da44e;--color-search-keyword-hl: #fff8c5;--color-prettylights-syntax-comment: #6e7781;--color-prettylights-syntax-constant: #0550ae;--color-prettylights-syntax-entity: #8250df;--color-prettylights-syntax-storage-modifier-import: #24292f;--color-prettylights-syntax-entity-tag: #116329;--color-prettylights-syntax-keyword: #cf222e;--color-prettylights-syntax-string: #0a3069;--color-prettylights-syntax-variable: #953800;--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;--color-prettylights-syntax-invalid-illegal-bg: #82071e;--color-prettylights-syntax-carriage-return-text: #f6f8fa;--color-prettylights-syntax-carriage-return-bg: #cf222e;--color-prettylights-syntax-string-regexp: #116329;--color-prettylights-syntax-markup-list: #3b2300;--color-prettylights-syntax-markup-heading: #0550ae;--color-prettylights-syntax-markup-italic: #24292f;--color-prettylights-syntax-markup-bold: #24292f;--color-prettylights-syntax-markup-deleted-text: #82071e;--color-prettylights-syntax-markup-deleted-bg: #FFEBE9;--color-prettylights-syntax-markup-inserted-text: #116329;--color-prettylights-syntax-markup-inserted-bg: #dafbe1;--color-prettylights-syntax-markup-changed-text: #953800;--color-prettylights-syntax-markup-changed-bg: #ffd8b5;--color-prettylights-syntax-markup-ignored-text: #eaeef2;--color-prettylights-syntax-markup-ignored-bg: #0550ae;--color-prettylights-syntax-meta-diff-range: #8250df;--color-prettylights-syntax-brackethighlighter-angle: #57606a;--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;--color-prettylights-syntax-constant-other-reference-link: #0a3069;--color-codemirror-text: #24292f;--color-codemirror-bg: #ffffff;--color-codemirror-gutters-bg: #ffffff;--color-codemirror-guttermarker-text: #ffffff;--color-codemirror-guttermarker-subtle-text: #6e7781;--color-codemirror-linenumber-text: #57606a;--color-codemirror-cursor: #24292f;--color-codemirror-selection-bg: rgba(84,174,255,.4);--color-codemirror-activeline-bg: rgba(234,238,242,.5);--color-codemirror-matchingbracket-text: #24292f;--color-codemirror-lines-bg: #ffffff;--color-codemirror-syntax-comment: #24292f;--color-codemirror-syntax-constant: #0550ae;--color-codemirror-syntax-entity: #8250df;--color-codemirror-syntax-keyword: #cf222e;--color-codemirror-syntax-storage: #cf222e;--color-codemirror-syntax-string: #0a3069;--color-codemirror-syntax-support: #0550ae;--color-codemirror-syntax-variable: #953800;--color-checks-bg: #24292f;--color-checks-run-border-width: 0px;--color-checks-container-border-width: 0px;--color-checks-text-primary: #f6f8fa;--color-checks-text-secondary: #8c959f;--color-checks-text-link: #54aeff;--color-checks-btn-icon: #afb8c1;--color-checks-btn-hover-icon: #f6f8fa;--color-checks-btn-hover-bg: rgba(255,255,255,.125);--color-checks-input-text: #eaeef2;--color-checks-input-placeholder-text: #8c959f;--color-checks-input-focus-text: #8c959f;--color-checks-input-bg: #32383f;--color-checks-input-shadow: none;--color-checks-donut-error: #fa4549;--color-checks-donut-pending: #bf8700;--color-checks-donut-success: #2da44e;--color-checks-donut-neutral: #afb8c1;--color-checks-dropdown-text: #afb8c1;--color-checks-dropdown-bg: #32383f;--color-checks-dropdown-border: #424a53;--color-checks-dropdown-shadow: rgba(27,31,36,.3);--color-checks-dropdown-hover-text: #f6f8fa;--color-checks-dropdown-hover-bg: #424a53;--color-checks-dropdown-btn-hover-text: #f6f8fa;--color-checks-dropdown-btn-hover-bg: #32383f;--color-checks-scrollbar-thumb-bg: #57606a;--color-checks-header-label-text: #d0d7de;--color-checks-header-label-open-text: #f6f8fa;--color-checks-header-border: #32383f;--color-checks-header-icon: #8c959f;--color-checks-line-text: #d0d7de;--color-checks-line-num-text: rgba(140,149,159,.75);--color-checks-line-timestamp-text: #8c959f;--color-checks-line-hover-bg: #32383f;--color-checks-line-selected-bg: rgba(33,139,255,.15);--color-checks-line-selected-num-text: #54aeff;--color-checks-line-dt-fm-text: #24292f;--color-checks-line-dt-fm-bg: #9a6700;--color-checks-gate-bg: rgba(1Line truncated
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id='root'></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIAHNYgVzsYDpLWwYAAJswAAAZAAAAYTUxMGNjOTNhODY3MjE0YTJlYTAuanNvbu1abW/aOhT+K5a/lE6U2k6ct2lX2rpN90rTvnS6V7qjVzLBQEaIUeKsq9r+9yuHbIATiANpmabxpYHaT07Oc3x8nuPcw0kU87/GMICMYhSGvsU8xyXYZoQzBPvF/z+yBVcjlstBtuTh4EsG+1DyTGYw+HxfXO2EuPDcMSUu5w4b+RPmcEp5qKZHMlag2Uzk8RjEgo2BnHEwEwsOlmzKAUvGIJuJW5BxKaNkqm66TMUXHsrSoHCWikWUL2AfxiJkMhIJDO4LkyvmxlHCYeD4fRiKOF8kMHAf+3Ccp+U0z/b6kCWJkMUP6slu+lCyaXklchmK4q7825KHko+VOUzOYPAZfogSDsQEXEfTmQSvl8s4Ks256cOUZ3lcukq7XyZZKj9FBSxBxLlA9gXCnzAKkBVYzsC26b9QIcj0DgZITeDL0uml/97wiUg5+FOIuXrMZkRXIa7tINi263BHBe47Fs7ATIi5EbRXgcZ10O+jbzJPORjCUSpuM54OoQk8pdvw2PW8OvgPLE/CGSixjZC9CvKGT276kEnJwtmCJ7L8IRR5ImGgHm8eLZd8DIMJizP+2Gpwv84noUgk/yYNfOINiO4Tp9bhVylnkoMS2QTX1TxiOSfzh8oFRs6wCNHiz6ldMaU3FK4RquZiQt3n8EXdYNMkR9ZJznncfY8+zBL1XcIAAgAcDB6Gibog4AGopD5YJ4Eey+6SEPTuV7n58Ry8+gPcF8MBAA+rP/+tplvFd3bLIlmMHqQil7x39uLFgi3jaJTyi2k8+JK9OOuDFWwxoECEB0fMR/Y1mipipQBDeGkUMtS3tDgnZD+5hhS4aE0BbsOBX/hOOb931mJr/OHJOoJcVBK0QclUSNE7uzw7f6lxuP6s2HTx1uTV5tcrMApfiLR3prLKq+098Pq7YefnAyne8L+jLBrFvHf+Em6S9q6AA0O4MWYIgRmyAcE+1rY7TFE3BOONNWaZE2zARece38lwy08ZEKTZvJiNeBzMWHahDO0N4dso5aFy6xCeHxkSDdiNQeEPkOtsB0VDQjcNCXJgSDwJ309AU8dxtNomHjX6X08kTw3LWX+APHt3oXJENaGQ9cxx4K5/ZJGnLPH1CqS2rIlFZl7j+QOMtFXgnaqs2TeYp6lIy3GZZDLPYACXLMsK/VXRaxq2QhBzGMg0XxGxV6k69gRbFhlhyjCxRq7jTnBVqY44YKO4KDOkmE5jDhZsCTJ5F/MOBKpLdwlU/5kFqm8kUF2/a4HqYV1FIr8jgerpAgEjd3+6aCVQfaLLSForx9oLVL8ife1nESEdCFQtizr1uau9QNVw7UPT87MKVEtP5LWR3Vag6h0dSk+VyX8L1M4FKtYFakNnynSfcQ4TqC6tCtQ9O+J+Xeoco0vd6mR19V6k1zzmYVHgzqygrGzra2dV5vbBPZDRgotcBgAjhFClJv1H3WEiUpCVyGs1YnIHI6FKUCXFd0O0eyDRzeQ8MQVNAeCV/wlFkkkwZun8TS6lSMArsC1zRsXP3814y9J5wco2x1dxFM5B0xyTct3X6mq/YWsyJXLj6IKQFkQe7aeVu/0tstcwg1B5rlkerljzUJ0sXaMpzfkn+8qvYpZlvUsWyugrvyhyyeXuRsGPGZuNgsMpxLriauh+GzLobTQFLdSCQTPXH+daQ3Ffsog70O64UtVaHWl3rC/Bhp7f02n3iiW1J1atpbuvxefJCr6fRbrzUThyKBt7oW1brjceW9yrSveiZV6qXXA74wkoFlCUTIv+esZZqnRZsVo60PKetVPLe/R5tbxHm1UmRaRjLU+R1p0jyKvtz7XX8hTpx3LIqz3HPkzLU1xR3KS2VdBay9PKiYTbVNL/NFr+uQ6bT9fbOPlhs75g6Mm6sr+1fOdanjyNlleJ7xCJ51lVLd9mi9wr7j37CHHv0V9L3GP9BaOmZW3KPD2Q+WZynpiCpgBwwIO5PL9WQbtlgbFY97SWLLb9TpjB6MA3QHx/lRgxqnlTYKX3zJ6+wcUYmR47X4kkT0erQhW8F3ky7uKljjpUA8KIpR/4dkTXxvsc1DemqxuWuuWii2P6MkQM3iEozPrEMwmuInnXRWRsgBkFhH780VFAbNQ11LxZ0z2VnbBwREyUodDF2xrE0voWuLZmPqDjQ2z0k3R8KpZ08rIGsfXa8Rfv+Nw8/g9QSwMEFAAACAgAc1iBXFlSbmXYAQAAXAUAAAsAAAByZXBvcnQuanNvbs2UTYvcMAyG/4rR2R0mkw9ncuuxUHppoYdlDoqjJN5xbBMrbJch/70kk2UWhqGXhe5NEtb7Sg/CFxiIsUFGqC6Amie0v/14pjFClc4SIuPIv8xAUCVK5ftUlUmWqUJCM43Ixjuo8kRlO5VIaI2lCNXTZY2+NVAB5sle62OKZaEOSYYHwj1cX/7ARRUwhF0MpHfPESQwRb5KLNFDiS+lavKDIiqwPrZYUJ6TXtoN20U09n6yjbAeG8E9id4PJAJ2JNA1Ivb+RURiNq5bTMPon0nzNpDuRz+YaQAJ1uttx+tKd+Na4wiq4ihBezsNDio1v0dTZqUEdM7zWlg2O0lg7LbIT6z96kp/AmmmZhkHuYfqCb4bR8K34qfpehZfQ7BmG2fpPEPF40QSRoqT3aAhM+p+ILfmp/k0y3+RLLI2SdNDneSYHNJaFapN7knWJLC2JNgL9l1nSQwYRORXSx8AUOWPAB4/P0CqdV3k2JQ6y1JVNk1K5T3A9eY2J/HSkxPaGn02rlsPNBKOuhf1xOzdBxAt04dEy/w/Ez2t38qSXoA9o4UqlTe3JZncLd1LaC2eX9conk0IW/XNb14U3wFbfG7IPtxNAo2jH99whY3iZZYwoO6No+uifwFQSwECPwMUAAAICABzWIFc7GA6S1sGAACbMAAAGQAAAAAAAAAAAAAAtIEAAAAAYTUxMGNjOTNhODY3MjE0YTJlYTAuanNvblBLAQI/AxQAAAgIAHNYgVxZUm5l2AEAAFwFAAALAAAAAAAAAAAAAAC0gZIGAAByZXBvcnQuanNvblBLBQYAAAAAAgACAIAAAACTCAAAAAA=</script>
|
||||||
@@ -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,956 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import * as turf from '@turf/turf';
|
||||||
|
import apiService from './services/api';
|
||||||
|
import './styles/App.css';
|
||||||
|
|
||||||
|
const CityRow = React.memo(({ city, index, isPlaying, currentCityIndex, isSelected, onClick }) => {
|
||||||
|
let rowClass = '';
|
||||||
|
if (isPlaying && currentCityIndex >= 0) {
|
||||||
|
if (index === currentCityIndex) rowClass = 'current-row';
|
||||||
|
else if (index < currentCityIndex) rowClass = 'passed-row';
|
||||||
|
else if (index <= currentCityIndex + 5) rowClass = 'upcoming-row';
|
||||||
|
} else if (isSelected) {
|
||||||
|
rowClass = 'selected-row';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
id={`city-row-${index}`}
|
||||||
|
onClick={onClick}
|
||||||
|
className={rowClass}
|
||||||
|
>
|
||||||
|
<td>{index + 1}</td>
|
||||||
|
<td>{city.name}</td>
|
||||||
|
<td>{(city.population / 1000).toFixed(0)}k</td>
|
||||||
|
<td>{city.distance_km}km</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const APP = () => {
|
||||||
|
const mapContainerRef = useRef(null);
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const startMarkerRef = useRef(null);
|
||||||
|
const animationRef = useRef(null);
|
||||||
|
const popupRef = useRef(null);
|
||||||
|
// Refs mirror state so stale closures (e.g. style.load) always read the current value
|
||||||
|
const mapProjectionRef = useRef('globe');
|
||||||
|
const selectedPointRef = useRef({ lat: 51.5074, lon: -0.1278 });
|
||||||
|
const directionRef = useRef(90);
|
||||||
|
const lineOfSightDataRef = useRef(null);
|
||||||
|
const flightProgressRef = useRef(0);
|
||||||
|
const seekRef = useRef(null);
|
||||||
|
const currentCityIndexRef = useRef(-1);
|
||||||
|
|
||||||
|
const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 });
|
||||||
|
const [direction, setDirection] = useState(90);
|
||||||
|
const [lineOfSightData, setLineOfSightData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [apiError, setApiError] = useState(null);
|
||||||
|
const [flightSpeed, setFlightSpeed] = useState(1.0);
|
||||||
|
const [mapStyle, setMapStyle] = useState('basic');
|
||||||
|
const [mapProjection, setMapProjection] = useState('globe');
|
||||||
|
const [tolerance, setTolerance] = useState(50);
|
||||||
|
const [selectedCity, setSelectedCity] = useState(null);
|
||||||
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
|
const [currentCityIndex, setCurrentCityIndex] = useState(-1);
|
||||||
|
|
||||||
|
// Keep refs in sync with state so stale closures always have current values
|
||||||
|
useEffect(() => { mapProjectionRef.current = mapProjection; }, [mapProjection]);
|
||||||
|
useEffect(() => { selectedPointRef.current = selectedPoint; }, [selectedPoint]);
|
||||||
|
useEffect(() => { directionRef.current = direction; }, [direction]);
|
||||||
|
useEffect(() => { lineOfSightDataRef.current = lineOfSightData; }, [lineOfSightData]);
|
||||||
|
|
||||||
|
// Auto-scroll sidebar to current city during flight
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying && currentCityIndex >= 0) {
|
||||||
|
const row = document.getElementById(`city-row-${currentCityIndex}`);
|
||||||
|
if (row) row.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||||
|
}
|
||||||
|
}, [currentCityIndex, isPlaying]);
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
// Build GeoJSON for the line, splitting at the antimeridian for flat projections
|
||||||
|
const buildLineGeoJSON = (lineCoords) => {
|
||||||
|
const coords = lineCoords.map(c => [c.lon, c.lat]);
|
||||||
|
// Split segments wherever consecutive points cross the antimeridian (±180°)
|
||||||
|
const segments = [];
|
||||||
|
let current = [coords[0]];
|
||||||
|
for (let i = 1; i < coords.length; i++) {
|
||||||
|
const diff = coords[i][0] - coords[i - 1][0];
|
||||||
|
if (Math.abs(diff) > 180) {
|
||||||
|
segments.push(current);
|
||||||
|
current = [coords[i]];
|
||||||
|
} else {
|
||||||
|
current.push(coords[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
segments.push(current);
|
||||||
|
if (segments.length === 1) {
|
||||||
|
return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: segments[0] } };
|
||||||
|
}
|
||||||
|
return { type: 'Feature', properties: {}, geometry: { type: 'MultiLineString', coordinates: segments } };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register a subtle chevron arrow image for the symbol layer
|
||||||
|
const addArrowImage = (map) => {
|
||||||
|
const size = 20;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.strokeStyle = 'rgba(255, 107, 107, 0.75)';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
// Chevron pointing right; MapLibre rotates it to follow line direction
|
||||||
|
ctx.moveTo(size * 0.2, size * 0.22);
|
||||||
|
ctx.lineTo(size * 0.78, size * 0.5);
|
||||||
|
ctx.lineTo(size * 0.2, size * 0.78);
|
||||||
|
ctx.stroke();
|
||||||
|
// MapLibre v5 requires ImageData, not a raw canvas element
|
||||||
|
map.addImage('arrow-icon', ctx.getImageData(0, 0, size, size));
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Map initialisation (runs once) ---
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mapRef.current = new maplibregl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: getMapStyle(mapStyle),
|
||||||
|
center: [-0.1278, 51.5074],
|
||||||
|
zoom: 2,
|
||||||
|
pitch: 0,
|
||||||
|
projection: { type: 'globe' }
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current.on('style.load', () => {
|
||||||
|
setupMapLayers();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||||
|
if (mapRef.current) mapRef.current.remove();
|
||||||
|
};
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const setupMapLayers = () => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const isGlobe = mapProjectionRef.current === 'globe';
|
||||||
|
|
||||||
|
// 1. Register arrow image (cleared on style reload)
|
||||||
|
addArrowImage(map);
|
||||||
|
|
||||||
|
// 2. Sky and terrain — globe only
|
||||||
|
if (isGlobe) {
|
||||||
|
map.setSky({
|
||||||
|
'sky-color': '#199EF3',
|
||||||
|
'sky-horizon-blend': 0.5,
|
||||||
|
'horizon-color': '#ffffff',
|
||||||
|
'horizon-fog-blend': 0.5,
|
||||||
|
'fog-color': '#add8e6',
|
||||||
|
'fog-ground-blend': 0.5
|
||||||
|
});
|
||||||
|
if (!map.getSource('terrain')) {
|
||||||
|
map.addSource('terrain', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
|
||||||
|
encoding: 'terrarium',
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 15
|
||||||
|
});
|
||||||
|
}
|
||||||
|
map.setTerrain({ source: 'terrain', exaggeration: 1.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read current values from refs to avoid stale closure problems
|
||||||
|
const currentPoint = selectedPointRef.current;
|
||||||
|
const currentDirection = directionRef.current;
|
||||||
|
const currentLineData = lineOfSightDataRef.current;
|
||||||
|
|
||||||
|
// 3. Start marker
|
||||||
|
if (startMarkerRef.current) startMarkerRef.current.remove();
|
||||||
|
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
|
||||||
|
.setLngLat([currentPoint.lon, currentPoint.lat])
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
// 4. Preview line source and layer
|
||||||
|
if (!map.getSource('preview-line')) {
|
||||||
|
map.addSource('preview-line', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } }
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: 'preview-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'preview-line',
|
||||||
|
paint: { 'line-color': '#FF6B6B', 'line-width': 2, 'line-dasharray': [2, 2] }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Cities source and layers (Symbol/Circle for high performance & terrain alignment)
|
||||||
|
if (!map.getSource('cities')) {
|
||||||
|
map.addSource('cities', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'FeatureCollection', features: [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
// City dot (Circle)
|
||||||
|
map.addLayer({
|
||||||
|
id: 'cities-circle',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'cities',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 6,
|
||||||
|
'circle-color': '#e74c3c',
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// City label (Symbol)
|
||||||
|
map.addLayer({
|
||||||
|
id: 'cities-label',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'cities',
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'name'],
|
||||||
|
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||||
|
'text-size': 12,
|
||||||
|
'text-offset': [0, 1.5],
|
||||||
|
'text-anchor': 'top',
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
'text-ignore-placement': false
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#2c3e50',
|
||||||
|
'text-halo-color': '#ffffff',
|
||||||
|
'text-halo-width': 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle city clicks
|
||||||
|
map.on('click', 'cities-circle', (e) => {
|
||||||
|
const feature = e.features[0];
|
||||||
|
if (feature) {
|
||||||
|
const city = JSON.parse(feature.properties.cityData);
|
||||||
|
setSelectedCity(city);
|
||||||
|
showCityPopup(city);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('mouseenter', 'cities-circle', () => {
|
||||||
|
map.getCanvas().style.cursor = 'pointer';
|
||||||
|
});
|
||||||
|
map.on('mouseleave', 'cities-circle', () => {
|
||||||
|
map.getCanvas().style.cursor = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Restore result data if it exists
|
||||||
|
if (currentLineData) {
|
||||||
|
renderLineOnMap(currentLineData);
|
||||||
|
if (map.getLayer('preview-line')) {
|
||||||
|
map.setLayoutProperty('preview-line', 'visibility', 'none');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatePreviewLine(currentPoint, currentDirection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Projection change effect ---
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const applyProjection = () => {
|
||||||
|
if (mapProjection === 'globe') {
|
||||||
|
map.setProjection({ type: 'globe' });
|
||||||
|
map.easeTo({ pitch: 0, duration: 1000 });
|
||||||
|
map.setSky({
|
||||||
|
'sky-color': '#199EF3',
|
||||||
|
'sky-horizon-blend': 0.5,
|
||||||
|
'horizon-color': '#ffffff',
|
||||||
|
'horizon-fog-blend': 0.5,
|
||||||
|
'fog-color': '#add8e6',
|
||||||
|
'fog-ground-blend': 0.5
|
||||||
|
});
|
||||||
|
if (map.getSource('terrain')) {
|
||||||
|
map.setTerrain({ source: 'terrain', exaggeration: 1.5 });
|
||||||
|
}
|
||||||
|
} else if (mapProjection === 'perspective') {
|
||||||
|
map.setProjection({ type: 'mercator' });
|
||||||
|
map.easeTo({ pitch: 60, duration: 1000 });
|
||||||
|
if (map.getSource('terrain')) {
|
||||||
|
map.setTerrain({ source: 'terrain', exaggeration: 1.5 });
|
||||||
|
}
|
||||||
|
// Remove sky for flat perspective as it can look odd without a true globe
|
||||||
|
map.setSky(null);
|
||||||
|
} else {
|
||||||
|
// Flat mode
|
||||||
|
map.setProjection({ type: 'mercator' });
|
||||||
|
map.easeTo({ pitch: 0, duration: 1000 });
|
||||||
|
map.setTerrain(null);
|
||||||
|
map.setSky(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render line with correct antimeridian handling for this projection
|
||||||
|
if (lineOfSightData && map.getSource('line-of-sight')) {
|
||||||
|
map.getSource('line-of-sight').setData(
|
||||||
|
buildLineGeoJSON(lineOfSightData.line_coordinates)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
applyProjection();
|
||||||
|
} else {
|
||||||
|
map.once('style.load', applyProjection);
|
||||||
|
return () => map.off('style.load', applyProjection);
|
||||||
|
}
|
||||||
|
}, [mapProjection]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// --- Click handler (respects isLocked) ---
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
if (isLocked) return;
|
||||||
|
const { lng, lat } = e.lngLat;
|
||||||
|
// Update ref immediately (state update is async)
|
||||||
|
selectedPointRef.current = { lat, lon: lng };
|
||||||
|
setSelectedPoint({ lat, lon: lng });
|
||||||
|
if (startMarkerRef.current) {
|
||||||
|
startMarkerRef.current.setLngLat([lng, lat]);
|
||||||
|
}
|
||||||
|
syncCitiesToMap([]);
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
||||||
|
if (map.getSource('line-of-sight')) {
|
||||||
|
map.removeLayer('line-of-sight');
|
||||||
|
map.removeSource('line-of-sight');
|
||||||
|
}
|
||||||
|
if (map.getLayer('preview-line')) {
|
||||||
|
map.setLayoutProperty('preview-line', 'visibility', 'visible');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mapRef.current.on('click', handleClick);
|
||||||
|
return () => {
|
||||||
|
if (mapRef.current) mapRef.current.off('click', handleClick);
|
||||||
|
};
|
||||||
|
}, [isLocked]);
|
||||||
|
|
||||||
|
// Update preview line when direction or point changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLocked) {
|
||||||
|
updatePreviewLine(selectedPoint, direction);
|
||||||
|
}
|
||||||
|
}, [direction, selectedPoint, isLocked]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// --- Map style change ---
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.setStyle(getMapStyle(mapStyle));
|
||||||
|
}
|
||||||
|
}, [mapStyle]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
|
||||||
|
const handleStartAgain = () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setFlightSpeed(1.0);
|
||||||
|
}
|
||||||
|
flightProgressRef.current = 0;
|
||||||
|
setCurrentCityIndex(-1);
|
||||||
|
currentCityIndexRef.current = -1;
|
||||||
|
if (popupRef.current) {
|
||||||
|
popupRef.current.remove();
|
||||||
|
popupRef.current = null;
|
||||||
|
}
|
||||||
|
setIsLocked(false);
|
||||||
|
setLineOfSightData(null);
|
||||||
|
setSelectedCity(null);
|
||||||
|
setApiError(null);
|
||||||
|
syncCitiesToMap([]);
|
||||||
|
if (mapRef.current) {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
||||||
|
if (map.getSource('line-of-sight')) {
|
||||||
|
map.removeLayer('line-of-sight');
|
||||||
|
map.removeSource('line-of-sight');
|
||||||
|
}
|
||||||
|
if (map.getLayer('preview-line')) {
|
||||||
|
map.setLayoutProperty('preview-line', 'visibility', 'visible');
|
||||||
|
}
|
||||||
|
map.flyTo({ center: [selectedPoint.lon, selectedPoint.lat], zoom: 3, pitch: 0, bearing: 0 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncCitiesToMap = (cities) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.getSource('cities')) return;
|
||||||
|
|
||||||
|
const geojson = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: cities.map((city) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [city.lon, city.lat] },
|
||||||
|
properties: {
|
||||||
|
name: city.name,
|
||||||
|
cityData: JSON.stringify(city)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
map.getSource('cities').setData(geojson);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const getCountryName = (code) => {
|
||||||
|
try {
|
||||||
|
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||||
|
return regionNames.of(code.toUpperCase()) || code;
|
||||||
|
} catch (e) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCityPopup = (city) => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
if (popupRef.current) popupRef.current.remove();
|
||||||
|
const popupNode = document.createElement('div');
|
||||||
|
popupNode.className = 'map-info-popup';
|
||||||
|
const countryName = getCountryName(city.country);
|
||||||
|
popupNode.innerHTML = `
|
||||||
|
<div class="popup-header">
|
||||||
|
<strong>${city.name}</strong>, ${countryName}
|
||||||
|
</div>
|
||||||
|
<div class="popup-body">
|
||||||
|
<p>Population: ${city.population.toLocaleString()}</p>
|
||||||
|
<p>Dist. from start: ${city.distance_km} km</p>
|
||||||
|
<p>Deviation: ${city.off_line_km} km</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
popupRef.current = new maplibregl.Popup({
|
||||||
|
closeButton: true,
|
||||||
|
closeOnClick: false,
|
||||||
|
maxWidth: '300px',
|
||||||
|
offset: [0, -20]
|
||||||
|
})
|
||||||
|
.setLngLat([city.lon, city.lat])
|
||||||
|
.setDOMContent(popupNode)
|
||||||
|
.addTo(mapRef.current);
|
||||||
|
popupRef.current.on('close', () => setSelectedCity(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startFlyOver = () => {
|
||||||
|
if (!lineOfSightData || !mapRef.current) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setFlightSpeed(1.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPlaying(true);
|
||||||
|
// Don't reset currentCityIndex here, so it resumes correctly
|
||||||
|
if (popupRef.current) popupRef.current.remove();
|
||||||
|
|
||||||
|
const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]);
|
||||||
|
const route = turf.lineString(coordinates);
|
||||||
|
const routeDistance = turf.length(route);
|
||||||
|
|
||||||
|
let lastFetchedDist = flightProgressRef.current;
|
||||||
|
let currentProgress = flightProgressRef.current;
|
||||||
|
// Removed: flightProgressRef.current = 0; - We keep the ref to resume.
|
||||||
|
let lastTimestamp = performance.now();
|
||||||
|
let currentSpeedMultiplier = 1.0;
|
||||||
|
let frameCount = 0;
|
||||||
|
|
||||||
|
async function animate(currentTime) {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
// Apply any pending seek
|
||||||
|
if (seekRef.current !== null) {
|
||||||
|
currentProgress = seekRef.current;
|
||||||
|
lastFetchedDist = currentProgress;
|
||||||
|
lastTimestamp = currentTime;
|
||||||
|
seekRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaTime = currentTime - lastTimestamp;
|
||||||
|
lastTimestamp = currentTime;
|
||||||
|
|
||||||
|
const baseKmPerMs = 0.1;
|
||||||
|
const pathPoints = lineOfSightDataRef.current.line_coordinates;
|
||||||
|
const pointIndex = Math.min(
|
||||||
|
Math.floor((currentProgress / routeDistance) * pathPoints.length),
|
||||||
|
pathPoints.length - 1
|
||||||
|
);
|
||||||
|
const isOverWater = pathPoints[pointIndex]?.is_over_water;
|
||||||
|
|
||||||
|
let futureIsOverWater = true;
|
||||||
|
const lookAheadDistance = 2000;
|
||||||
|
const lookAheadSteps = 10;
|
||||||
|
for (let i = 1; i <= lookAheadSteps; i++) {
|
||||||
|
const checkDist = currentProgress + (lookAheadDistance * i) / lookAheadSteps;
|
||||||
|
const checkIndex = Math.min(
|
||||||
|
Math.floor((checkDist / routeDistance) * pathPoints.length),
|
||||||
|
pathPoints.length - 1
|
||||||
|
);
|
||||||
|
if (pathPoints[checkIndex] && !pathPoints[checkIndex].is_over_water) {
|
||||||
|
futureIsOverWater = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMultiplier = (isOverWater && futureIsOverWater) ? 20.0 : 1.0;
|
||||||
|
const acceleration = 0.005;
|
||||||
|
if (currentSpeedMultiplier < targetMultiplier) {
|
||||||
|
currentSpeedMultiplier = Math.min(currentSpeedMultiplier + acceleration * deltaTime, targetMultiplier);
|
||||||
|
} else if (currentSpeedMultiplier > targetMultiplier) {
|
||||||
|
currentSpeedMultiplier = Math.max(currentSpeedMultiplier - acceleration * deltaTime, targetMultiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
frameCount++;
|
||||||
|
if (frameCount % 10 === 0) setFlightSpeed(currentSpeedMultiplier);
|
||||||
|
|
||||||
|
currentProgress += baseKmPerMs * deltaTime * currentSpeedMultiplier;
|
||||||
|
const phase = Math.min(currentProgress / routeDistance, 1);
|
||||||
|
|
||||||
|
if (phase >= 1) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setFlightSpeed(1.0);
|
||||||
|
setCurrentCityIndex(-1);
|
||||||
|
currentCityIndexRef.current = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eye = turf.along(route, currentProgress).geometry.coordinates;
|
||||||
|
const target = turf.along(route, Math.min(currentProgress + 10, routeDistance)).geometry.coordinates;
|
||||||
|
const bearing = turf.bearing(turf.point(eye), turf.point(target));
|
||||||
|
|
||||||
|
mapRef.current.jumpTo({ center: eye, zoom: 6, pitch: 65, bearing });
|
||||||
|
|
||||||
|
// Find nearest city to current position and update sidebar highlight
|
||||||
|
const cities = lineOfSightDataRef.current.conurbations;
|
||||||
|
let nearestIdx = -1;
|
||||||
|
let nearestDist = Infinity;
|
||||||
|
cities.forEach((city, idx) => {
|
||||||
|
const d = Math.abs(city.distance_km - currentProgress);
|
||||||
|
if (d < nearestDist) { nearestDist = d; nearestIdx = idx; }
|
||||||
|
});
|
||||||
|
if (nearestDist < 300 && nearestIdx !== currentCityIndexRef.current) {
|
||||||
|
currentCityIndexRef.current = nearestIdx;
|
||||||
|
setCurrentCityIndex(nearestIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentProgress - lastFetchedDist > 2000) {
|
||||||
|
lastFetchedDist = currentProgress;
|
||||||
|
try {
|
||||||
|
const response = await apiService.getLineOfSight(eye[1], eye[0], bearing, tolerance);
|
||||||
|
if (response.data.success) {
|
||||||
|
const newCities = response.data.data.conurbations;
|
||||||
|
setLineOfSightData(prev => {
|
||||||
|
const existingIds = new Set(prev.conurbations.map(c => c.id));
|
||||||
|
const uniqueNewCities = newCities.filter(c => !existingIds.has(c.id));
|
||||||
|
const adjustedCities = uniqueNewCities.map(c => ({
|
||||||
|
...c,
|
||||||
|
distance_km: Math.round(c.distance_km + currentProgress)
|
||||||
|
}));
|
||||||
|
const updatedData = {
|
||||||
|
...prev,
|
||||||
|
conurbations: [...prev.conurbations, ...adjustedCities].sort((a, b) => a.distance_km - b.distance_km)
|
||||||
|
};
|
||||||
|
syncCitiesToMap(updatedData.conurbations);
|
||||||
|
return updatedData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching more cities during flight:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flightProgressRef.current = currentProgress;
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLineOnMap = (data) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
// Remove existing layers/source (arrow layer must go before line layer)
|
||||||
|
if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
|
||||||
|
if (map.getSource('line-of-sight')) {
|
||||||
|
map.removeLayer('line-of-sight');
|
||||||
|
map.removeSource('line-of-sight');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projection = mapProjectionRef.current;
|
||||||
|
|
||||||
|
syncCitiesToMap([]);
|
||||||
|
|
||||||
|
map.addSource('line-of-sight', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: buildLineGeoJSON(data.line_coordinates)
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'line-of-sight',
|
||||||
|
type: 'line',
|
||||||
|
source: 'line-of-sight',
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: { 'line-color': '#FF6B6B', 'line-width': 4, 'line-opacity': 0.8 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subtle periodic directional arrows along the line
|
||||||
|
map.addLayer({
|
||||||
|
id: 'line-of-sight-arrows',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'line-of-sight',
|
||||||
|
layout: {
|
||||||
|
'symbol-placement': 'line',
|
||||||
|
'symbol-spacing': 300,
|
||||||
|
'icon-image': 'arrow-icon',
|
||||||
|
'icon-size': 1.0,
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'icon-ignore-placement': true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addArrowImage(map);
|
||||||
|
syncCitiesToMap(data.conurbations);
|
||||||
|
|
||||||
|
// Zoom to show the full path from the start point
|
||||||
|
map.flyTo({
|
||||||
|
center: [data.line_coordinates[0].lon, data.line_coordinates[0].lat],
|
||||||
|
zoom: 2,
|
||||||
|
pitch: 0,
|
||||||
|
bearing: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePreviewLine = (point, brng) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.getSource('preview-line')) return;
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
const steps = 160;
|
||||||
|
const totalDistance = 40074; // Full Earth circumference (km)
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const dist = (totalDistance * i) / steps;
|
||||||
|
path.push(calculateDestination(point.lat, point.lon, brng, dist));
|
||||||
|
}
|
||||||
|
|
||||||
|
map.getSource('preview-line').setData(
|
||||||
|
buildLineGeoJSON(path)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||||
|
const R = 6371;
|
||||||
|
const brng = (bearing * Math.PI) / 180;
|
||||||
|
const φ1 = (lat * Math.PI) / 180;
|
||||||
|
const λ1 = (lon * Math.PI) / 180;
|
||||||
|
const δ = distance / R;
|
||||||
|
|
||||||
|
const φ2 = Math.asin(
|
||||||
|
Math.sin(φ1) * Math.cos(δ) +
|
||||||
|
Math.cos(φ1) * Math.sin(δ) * Math.cos(brng)
|
||||||
|
);
|
||||||
|
const λ2 =
|
||||||
|
λ1 +
|
||||||
|
Math.atan2(
|
||||||
|
Math.sin(brng) * Math.sin(δ) * Math.cos(φ1),
|
||||||
|
Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: (φ2 * 180) / Math.PI,
|
||||||
|
lon: (((λ2 * 180) / Math.PI + 540) % 360) - 180
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMapStyle = (style) => {
|
||||||
|
if (style === 'satellite') {
|
||||||
|
return {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
'esri-satellite': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: 'Tiles © 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
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (style === 'map') {
|
||||||
|
return 'https://tiles.openfreemap.org/styles/liberty';
|
||||||
|
}
|
||||||
|
// 'basic' — clean MapLibre demo tiles, no labels
|
||||||
|
return 'https://demotiles.maplibre.org/style.json';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocateMe = () => {
|
||||||
|
if (!navigator.geolocation) return;
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const { latitude: lat, longitude: lon } = pos.coords;
|
||||||
|
selectedPointRef.current = { lat, lon };
|
||||||
|
setSelectedPoint({ lat, lon });
|
||||||
|
if (startMarkerRef.current) {
|
||||||
|
startMarkerRef.current.setLngLat([lon, lat]);
|
||||||
|
}
|
||||||
|
mapRef.current?.flyTo({ center: [lon, lat], zoom: 5 });
|
||||||
|
},
|
||||||
|
(err) => console.error('Geolocation error:', err)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowLineOfSight = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setSelectedCity(null);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const response = await apiService.getLineOfSight(
|
||||||
|
selectedPoint.lat,
|
||||||
|
selectedPoint.lon,
|
||||||
|
direction,
|
||||||
|
tolerance
|
||||||
|
);
|
||||||
|
setLineOfSightData(response.data.data);
|
||||||
|
setIsLocked(true);
|
||||||
|
if (mapRef.current && mapRef.current.getLayer('preview-line')) {
|
||||||
|
mapRef.current.setLayoutProperty('preview-line', 'visibility', 'none');
|
||||||
|
}
|
||||||
|
renderLineOnMap(response.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching line of sight:', error);
|
||||||
|
const msg = error.response?.data?.error || error.message || 'Failed to fetch route.';
|
||||||
|
setApiError(`Error: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
// Reset flight progress for a new line calculation
|
||||||
|
flightProgressRef.current = 0;
|
||||||
|
setCurrentCityIndex(-1);
|
||||||
|
currentCityIndexRef.current = -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECTIONS = [
|
||||||
|
{ id: 'globe', label: 'Globe' },
|
||||||
|
{ id: 'perspective', label: 'Perspective' },
|
||||||
|
{ id: 'flat', label: 'Flat' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-container">
|
||||||
|
<div className="map-container" ref={mapContainerRef}>
|
||||||
|
{!isLocked && (
|
||||||
|
<button
|
||||||
|
className="locate-btn"
|
||||||
|
onClick={handleLocateMe}
|
||||||
|
title="Use my current location"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<line x1="12" y1="2" x2="12" y2="6"/>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="22"/>
|
||||||
|
<line x1="2" y1="12" x2="6" y2="12"/>
|
||||||
|
<line x1="18" y1="12" x2="22" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls">
|
||||||
|
<div className={`control-group ${isLocked ? 'disabled-controls' : ''}`}>
|
||||||
|
<h3>Line of Sight Settings</h3>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>Start Point:</label>
|
||||||
|
<span>{selectedPoint.lat.toFixed(4)}, {selectedPoint.lon.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>Direction (0-360°):</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
value={direction}
|
||||||
|
disabled={isLocked}
|
||||||
|
onChange={(e) => setDirection(parseInt(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span>{direction}°</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>Fuzziness Tolerance (km):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={tolerance}
|
||||||
|
disabled={isLocked}
|
||||||
|
onChange={(e) => setTolerance(parseInt(e.target.value))}
|
||||||
|
min="10"
|
||||||
|
max="200"
|
||||||
|
/>
|
||||||
|
<span>{tolerance} km</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>Map Style:</label>
|
||||||
|
<button className={mapStyle === 'basic' ? 'active-style' : ''} onClick={() => setMapStyle('basic')}>Basic</button>
|
||||||
|
<button className={mapStyle === 'map' ? 'active-style' : ''} onClick={() => setMapStyle('map')}>Map</button>
|
||||||
|
<button className={mapStyle === 'satellite' ? 'active-style' : ''} onClick={() => setMapStyle('satellite')}>Satellite</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row projection-row">
|
||||||
|
<label>Projection:</label>
|
||||||
|
<div className="projection-buttons">
|
||||||
|
{PROJECTIONS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className={mapProjection === p.id ? 'active-style' : ''}
|
||||||
|
onClick={() => setMapProjection(p.id)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apiError && (
|
||||||
|
<div className="error-banner">
|
||||||
|
⚠️ {apiError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLocked ? (
|
||||||
|
<button className="action-btn" onClick={handleShowLineOfSight} disabled={loading}>
|
||||||
|
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="fly-controls">
|
||||||
|
<button
|
||||||
|
className={`fly-button ${isPlaying ? 'pause' : 'play'}`}
|
||||||
|
onClick={startFlyOver}
|
||||||
|
disabled={!lineOfSightData}
|
||||||
|
>
|
||||||
|
{isPlaying ? `Pause Flyover (${flightSpeed.toFixed(1)}x)` : (flightProgressRef.current > 0 ? 'Resume Flyover' : 'Start Flyover')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="reset-flight-btn"
|
||||||
|
onClick={() => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setFlightSpeed(1.0);
|
||||||
|
flightProgressRef.current = 0;
|
||||||
|
setCurrentCityIndex(-1);
|
||||||
|
currentCityIndexRef.current = -1;
|
||||||
|
// Move camera back to start
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: [selectedPoint.lon, selectedPoint.lat],
|
||||||
|
zoom: 4,
|
||||||
|
pitch: 0,
|
||||||
|
bearing: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!lineOfSightData || (flightProgressRef.current === 0 && !isPlaying)}
|
||||||
|
title="Reset flight to beginning"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="action-btn-secondary" onClick={handleStartAgain}>
|
||||||
|
🔄 Start Again
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lineOfSightData && (
|
||||||
|
<div className="results-panel">
|
||||||
|
<h3>Cities Along Route ({lineOfSightData.conurbations.length})</h3>
|
||||||
|
{isPlaying && currentCityIndex >= 0 && (
|
||||||
|
<div className="now-passing">
|
||||||
|
Now passing: <strong>{lineOfSightData.conurbations[currentCityIndex]?.name}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="hint">{isPlaying ? 'Click a city to jump to it' : 'Click a city for details'}</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>City</th>
|
||||||
|
<th>Pop.</th>
|
||||||
|
<th>Dist.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lineOfSightData.conurbations.map((city, index) => (
|
||||||
|
<CityRow
|
||||||
|
key={city.id}
|
||||||
|
city={city}
|
||||||
|
index={index}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
currentCityIndex={currentCityIndex}
|
||||||
|
isSelected={selectedCity?.id === city.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCity(city);
|
||||||
|
showCityPopup(city);
|
||||||
|
mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 });
|
||||||
|
if (isPlaying) {
|
||||||
|
seekRef.current = city.distance_km;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="instructions">
|
||||||
|
<h3>How to Use</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click anywhere on the map to select a starting point</li>
|
||||||
|
<li>Adjust the direction using the slider (0-360°)</li>
|
||||||
|
<li>Set your fuzziness tolerance (how close cities must be to the line)</li>
|
||||||
|
<li>Click "Show Line of Sight" to visualize the path and cities</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default APP;
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Placeholder test file for Line of Sight Frontend
|
|
||||||
*
|
|
||||||
* TODO: Add real tests for:
|
|
||||||
* - Map component rendering
|
|
||||||
* - Direction selector functionality
|
|
||||||
* - API integration
|
|
||||||
* - User interactions
|
|
||||||
* - Line of sight visualization
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('Line of Sight Frontend', () => {
|
|
||||||
describe('App Component', () => {
|
|
||||||
test('should render map container', () => {
|
|
||||||
// TODO: Implement component rendering test
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display direction selector', () => {
|
|
||||||
// TODO: Implement direction selector test
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle map click events', () => {
|
|
||||||
// TODO: Implement click event test
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API Integration', () => {
|
|
||||||
test('should fetch line of sight data', () => {
|
|
||||||
// TODO: Implement API fetch test
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle API errors gracefully', () => {
|
|
||||||
// TODO: Implement error handling test
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('UI Components', () => {
|
|
||||||
test('should toggle map style between light/dark', () => {
|
|
||||||
// TODO: Implement style toggle test
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display conurbation results table', () => {
|
|
||||||
// TODO: Implement results table test
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { vi, describe, test, expect, beforeEach } from 'vitest';
|
||||||
|
import App from '../App';
|
||||||
|
import apiService from '../services/api';
|
||||||
|
|
||||||
|
// Mock MapLibre GL
|
||||||
|
vi.mock('maplibre-gl', () => {
|
||||||
|
const mInstance = {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
addSource: vi.fn(),
|
||||||
|
removeSource: vi.fn(),
|
||||||
|
addLayer: vi.fn(),
|
||||||
|
removeLayer: vi.fn(),
|
||||||
|
getSource: vi.fn(() => ({ setData: vi.fn() })),
|
||||||
|
getLayer: vi.fn(),
|
||||||
|
setLayoutProperty: vi.fn(),
|
||||||
|
setStyle: vi.fn(),
|
||||||
|
flyTo: vi.fn(),
|
||||||
|
jumpTo: vi.fn(),
|
||||||
|
fitBounds: vi.fn(),
|
||||||
|
isStyleLoaded: vi.fn(() => true),
|
||||||
|
setSky: vi.fn(),
|
||||||
|
setTerrain: vi.fn(),
|
||||||
|
setProjection: vi.fn(),
|
||||||
|
hasImage: vi.fn(() => false),
|
||||||
|
addImage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
Map: vi.fn(() => mInstance),
|
||||||
|
Marker: vi.fn(() => ({
|
||||||
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
|
addTo: vi.fn().mockReturnThis(),
|
||||||
|
remove: vi.fn().mockReturnThis(),
|
||||||
|
getElement: vi.fn(() => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
// Add a simple way to trigger click for tests if needed
|
||||||
|
return el;
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
Popup: vi.fn(() => ({
|
||||||
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
|
setDOMContent: vi.fn().mockReturnThis(),
|
||||||
|
addTo: vi.fn().mockReturnThis(),
|
||||||
|
remove: vi.fn().mockReturnThis(),
|
||||||
|
on: vi.fn().mockReturnThis(),
|
||||||
|
})),
|
||||||
|
LngLatBounds: vi.fn(() => ({
|
||||||
|
extend: vi.fn().mockReturnThis(),
|
||||||
|
})),
|
||||||
|
MercatorCoordinate: {
|
||||||
|
fromLngLat: vi.fn(() => ({ x: 0, y: 0, z: 0 })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock apiService
|
||||||
|
vi.mock('../services/api', () => ({
|
||||||
|
default: {
|
||||||
|
getLineOfSight: vi.fn(),
|
||||||
|
healthCheck: vi.fn(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('App Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the application title', () => {
|
||||||
|
render(<App />);
|
||||||
|
expect(screen.getByText(/Line of Sight Settings/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders initial controls', () => {
|
||||||
|
render(<App />);
|
||||||
|
// Use role or more specific text to avoid duplicates
|
||||||
|
expect(screen.getByText(/Direction \(0-360°\):/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Show Line of Sight/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls API when clicking "Show Line of Sight"', async () => {
|
||||||
|
apiService.getLineOfSight.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
conurbations: [],
|
||||||
|
line_coordinates: [{ lat: 0, lon: 0 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
const button = screen.getByRole('button', { name: /Show Line of Sight/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiService.getLineOfSight).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggles map style', () => {
|
||||||
|
render(<App />);
|
||||||
|
const darkButton = screen.getByRole('button', { name: /Dark/i });
|
||||||
|
fireEvent.click(darkButton);
|
||||||
|
expect(darkButton).toHaveClass('active-style');
|
||||||
|
});
|
||||||
|
});
|
||||||
File renamed without changes.
@@ -1,13 +1,10 @@
|
|||||||
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 || '/api';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
timeout: 10000,
|
timeout: 30000
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getLineOfSight = async (lat, lon, direction, tolerance) => {
|
export const getLineOfSight = async (lat, lon, direction, tolerance) => {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
// Global mocks if needed
|
||||||
+340
-29
@@ -3,6 +3,7 @@
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-container {
|
.map-container {
|
||||||
@@ -12,6 +13,30 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locate-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #333;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locate-btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #1a73e8;
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
background: white;
|
background: white;
|
||||||
@@ -19,6 +44,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 +88,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 +105,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,44 +134,193 @@
|
|||||||
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 {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel tr {
|
||||||
|
will-change: transform, background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
@@ -172,41 +354,67 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-marker {
|
.map-info-popup {
|
||||||
display: flex;
|
padding: 5px;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marker-dot {
|
.popup-header {
|
||||||
width: 12px;
|
border-bottom: 1px solid #eee;
|
||||||
height: 12px;
|
padding-bottom: 5px;
|
||||||
background: #e74c3c;
|
margin-bottom: 5px;
|
||||||
border: 2px solid white;
|
color: #2c3e50;
|
||||||
border-radius: 50%;
|
font-size: 14px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.marker-label {
|
.popup-body {
|
||||||
background: white;
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body p {
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup {
|
||||||
|
z-index: 2000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3) !important;
|
||||||
|
padding: 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-buttons button {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: bold;
|
border: 1px solid #ddd;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
background: white;
|
||||||
margin-top: 2px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marker-pop {
|
.projection-buttons button:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projection-buttons button.active-style {
|
||||||
background: #34495e;
|
background: #34495e;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 2px 6px;
|
border-color: #2c3e50;
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
margin-top: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -223,4 +431,107 @@
|
|||||||
.map-container {
|
.map-container {
|
||||||
height: 60vh;
|
height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-panel-modal {
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: auto;
|
||||||
|
bottom: 20px;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel tr.passed-row {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel tr.current-row {
|
||||||
|
background: #1abc9c !important;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel tr.upcoming-row {
|
||||||
|
background: #eaf4fb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-passing {
|
||||||
|
background: #1abc9c;
|
||||||
|
color: white;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #fdecea;
|
||||||
|
color: #d32f2f;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid #d32f2f;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 10px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button {
|
||||||
|
flex: 3;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button.play {
|
||||||
|
background: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button.play:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button.pause {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fly-button.pause:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-flight-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
background: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-flight-btn:hover:not(:disabled) {
|
||||||
|
background: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-flight-btn:disabled {
|
||||||
|
background: #ecf0f1;
|
||||||
|
color: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'happy-dom',
|
||||||
|
setupFiles: ['./src/setupTests.js'],
|
||||||
|
exclude: ['**/e2e/**', '**/node_modules/**'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3050,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:3051',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'build',
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
esbuildOptions: {
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONTAINER_NAME="line-of-sight-db"
|
||||||
|
DB_USER="line_of_sight"
|
||||||
|
DB_NAME="line_of_sight"
|
||||||
|
DATA_FILE="./docker/data/cities.csv.gz"
|
||||||
|
|
||||||
|
# Check if docker is available using the known path
|
||||||
|
DOCKER_BIN="/usr/local/bin/docker"
|
||||||
|
if [ ! -f "$DOCKER_BIN" ]; then
|
||||||
|
DOCKER_BIN=$(which docker)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$DOCKER_BIN" ]; then
|
||||||
|
echo "Error: docker command not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if data file exists
|
||||||
|
if [ ! -f "$DATA_FILE" ]; then
|
||||||
|
echo "Error: Data file $DATA_FILE not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if container is running
|
||||||
|
if ! "$DOCKER_BIN" ps | grep -q "$CONTAINER_NAME"; then
|
||||||
|
echo "Error: Container $CONTAINER_NAME is not running."
|
||||||
|
echo "Please run 'docker-compose up -d' first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Starting database initialization..."
|
||||||
|
|
||||||
|
# Use a HEREDOC to execute multi-line SQL
|
||||||
|
# We load into a temp table first to handle the WKT -> Geography conversion
|
||||||
|
IMPORT_SQL=$(cat <<EOF
|
||||||
|
CREATE TEMP TABLE tmp_cities (
|
||||||
|
name VARCHAR(255),
|
||||||
|
population INTEGER,
|
||||||
|
country VARCHAR(100),
|
||||||
|
geom_wkt TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
COPY tmp_cities FROM STDIN WITH CSV HEADER;
|
||||||
|
|
||||||
|
TRUNCATE cities;
|
||||||
|
|
||||||
|
INSERT INTO cities (name, population, country, geom)
|
||||||
|
SELECT
|
||||||
|
name,
|
||||||
|
population,
|
||||||
|
country,
|
||||||
|
ST_GeomFromText(geom_wkt, 4326)::geography
|
||||||
|
FROM tmp_cities;
|
||||||
|
|
||||||
|
DROP TABLE tmp_cities;
|
||||||
|
|
||||||
|
SELECT count(*) || ' cities successfully imported.' as result FROM cities;
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream the gzipped data into psql
|
||||||
|
gunzip -c "$DATA_FILE" | "$DOCKER_BIN" exec -i -e PGPASSWORD=line_of_sight_pass "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -h 127.0.0.1 -c "$IMPORT_SQL"
|
||||||
|
|
||||||
|
echo "✅ Initialization complete."
|
||||||
Reference in new issue
Block a user