Modernize stack (React 19, Vite, Node 24) and add map preview features
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# GEMINI.md - Project Context: Line of Sight 🌍
|
||||||
|
|
||||||
|
## 🚀 Project Overview
|
||||||
|
**Line of Sight** is an interactive web application that visualizes great-circle paths around the Earth and identifies major conurbations (cities) along those paths based on user-defined direction and "fuzziness" tolerance.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Frontend**: React 19 single-page application using Vite 6 for build orchestration and MapLibre GL JS for vector map rendering.
|
||||||
|
- **Backend**: Node.js 24/Express 5 REST API.
|
||||||
|
- **Database**: PostgreSQL with the PostGIS extension for geospatial data storage and analysis. Uses `kartoza/postgis` for robust multi-arch (including ARM64) support.
|
||||||
|
- **Infrastructure**: Fully containerized using Docker and orchestrated with Docker Compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
- **Frontend**: `React 19`, `Vite 6`, `MapLibre GL JS`, `Axios`
|
||||||
|
- **Backend**: `Node.js 24`, `Express 5`, `node-postgres (pg) 8.20`
|
||||||
|
- **Database**: `PostgreSQL (kartoza/postgis)`, `PostGIS`
|
||||||
|
- **Testing**: `Vitest` (Frontend), `Jest` (Backend)
|
||||||
|
- **DevOps**: `Docker`, `Docker Compose`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚦 Getting Started & Key Commands
|
||||||
|
|
||||||
|
### Complete System (Docker)
|
||||||
|
| Action | Command |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Start Services** | `docker-compose up` |
|
||||||
|
| **Rebuild & Start** | `docker-compose up --build` |
|
||||||
|
| **Stop Services** | `docker-compose down` |
|
||||||
|
| **View Logs** | `docker-compose logs -f` |
|
||||||
|
| **Access DB (psql)** | `docker-compose exec postgres psql -U line_of_sight -d line_of_sight` |
|
||||||
|
|
||||||
|
### Backend Development (`/backend`)
|
||||||
|
| Action | Command |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Install** | `npm install` |
|
||||||
|
| **Start (Prod)** | `npm start` |
|
||||||
|
| **Start (Dev)** | `npm run dev` (Uses nodemon) |
|
||||||
|
| **Test** | `npm test` |
|
||||||
|
|
||||||
|
### Frontend Development (`/frontend`)
|
||||||
|
| Action | Command |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Install** | `npm install` |
|
||||||
|
| **Start (Dev)** | `npm run start` (Starts Vite) |
|
||||||
|
| **Build** | `npm run build` |
|
||||||
|
| **Test** | `npm run test` (Starts Vitest) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Key File Map
|
||||||
|
- `backend/app/server.js`: Main Express entry point and API route definitions.
|
||||||
|
- `frontend/src/App.js`: Primary React component containing map logic and state management.
|
||||||
|
- `frontend/src/services/api.js`: Axios-based API client for backend communication.
|
||||||
|
- `docker/init.sql`: Database schema definition including PostGIS extension and seed data.
|
||||||
|
- `docker-compose.yml`: Service orchestration for the entire stack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Development Conventions
|
||||||
|
- **Node Versioning**: Use `nvm` to manage Node.js. Run `nvm use` in the root directory to switch to Node 24 (specified in `.nvmrc`).
|
||||||
|
- **Geospatial Standards**: Latitude/Longitude are handled in decimal degrees. Geometry uses `SRID 4326` (WGS 84).
|
||||||
|
- **API Design**: RESTful endpoints returning JSON. The primary endpoint is `GET /api/line-of-sight`.
|
||||||
|
- **Styling**: Prefer custom CSS in `frontend/src/styles/` over utility-first frameworks like Tailwind for this specific project.
|
||||||
|
- **Testing**: Tests are located in `backend/tests/` and `frontend/src/__tests__/`. Use `vitest` (Frontend) and `jest` (Backend).
|
||||||
|
- **Environment**: Use `.env` files for configuration. `VITE_API_URL` is required for the frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Roadmap Highlights
|
||||||
|
- [ ] Implement real ST_DWithin() PostGIS queries in the backend.
|
||||||
|
- [ ] Import full Natural Earth/GeoNames datasets into the `cities` table.
|
||||||
|
- [ ] Transition from 2D MapLibre to a 3D globe visualization.
|
||||||
|
- [ ] Optimize line-of-sight calculations for long-distance paths.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
Generated
+5203
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,13 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^5.2.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.6",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.20.0",
|
||||||
"dotenv": "^16.3.1"
|
"dotenv": "^17.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.1.14",
|
||||||
"jest": "^29.7.0"
|
"jest": "^30.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-6
@@ -2,16 +2,18 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgis/postgis:15-3.3-alpine
|
image: kartoza/postgis:latest
|
||||||
|
platform: linux/arm64
|
||||||
container_name: line-of-sight-db
|
container_name: line-of-sight-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: line_of_sight
|
- POSTGRES_DB=line_of_sight
|
||||||
POSTGRES_USER: line_of_sight
|
- POSTGRES_USER=line_of_sight
|
||||||
POSTGRES_PASSWORD: line_of_sight_pass
|
- POSTGRES_PASS=line_of_sight_pass
|
||||||
|
- ALLOW_IP_RANGE=0.0.0.0/0
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql
|
||||||
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
|
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U line_of_sight"]
|
test: ["CMD-SHELL", "pg_isready -U line_of_sight"]
|
||||||
@@ -44,7 +46,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- REACT_APP_API_URL=http://localhost:3001/api
|
- VITE_API_URL=http://localhost:3001/api
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ COPY . .
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Set environment variable for API URL
|
# Set environment variable for API URL
|
||||||
ENV REACT_APP_API_URL=http://localhost:3001/api
|
ENV VITE_API_URL=http://localhost:3001/api
|
||||||
|
|
||||||
# Start development server
|
# Start development server
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -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
+3291
File diff suppressed because it is too large
Load Diff
+26
-11
@@ -3,20 +3,35 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"axios": "^1.7.9",
|
||||||
"react-dom": "^18.2.0",
|
"maplibre-gl": "^5.20.1",
|
||||||
"react-scripts": "5.0.1",
|
"react": "^19.2.1",
|
||||||
"maplibre-gl": "^3.6.2",
|
"react-dom": "^19.2.1"
|
||||||
"axios": "^1.6.2"
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"jsdom": "^29.0.0",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import './styles/App.css';
|
|||||||
const APP = () => {
|
const APP = () => {
|
||||||
const mapContainerRef = useRef(null);
|
const mapContainerRef = useRef(null);
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
|
const startMarkerRef = useRef(null);
|
||||||
const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 });
|
const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 });
|
||||||
const [direction, setDirection] = useState(45);
|
const [direction, setDirection] = useState(45);
|
||||||
const [lineOfSightData, setLineOfSightData] = useState(null);
|
const [lineOfSightData, setLineOfSightData] = useState(null);
|
||||||
@@ -26,13 +27,47 @@ const APP = () => {
|
|||||||
|
|
||||||
mapRef.current.on('load', () => {
|
mapRef.current.on('load', () => {
|
||||||
console.log('Map loaded successfully');
|
console.log('Map loaded successfully');
|
||||||
|
|
||||||
|
// Initialize start marker
|
||||||
|
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
|
||||||
|
.setLngLat([selectedPoint.lon, selectedPoint.lat])
|
||||||
|
.addTo(mapRef.current);
|
||||||
|
|
||||||
|
// Initialize preview line source and layer
|
||||||
|
mapRef.current.addSource('preview-line', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current.addLayer({
|
||||||
|
id: 'preview-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'preview-line',
|
||||||
|
paint: {
|
||||||
|
'line-color': '#FF6B6B',
|
||||||
|
'line-width': 2,
|
||||||
|
'line-dasharray': [2, 2]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updatePreviewLine(selectedPoint, direction);
|
||||||
});
|
});
|
||||||
|
|
||||||
mapRef.current.on('click', (e) => {
|
mapRef.current.on('click', (e) => {
|
||||||
const { lng, lat } = e.lngLat;
|
const { lng, lat } = e.lngLat;
|
||||||
setSelectedPoint({ lat, lon: lng });
|
setSelectedPoint({ lat, lon: lng });
|
||||||
|
|
||||||
// Clear previous line
|
if (startMarkerRef.current) {
|
||||||
|
startMarkerRef.current.setLngLat([lng, lat]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear previous final line when moving start point
|
||||||
if (mapRef.current.getSource('line-of-sight')) {
|
if (mapRef.current.getSource('line-of-sight')) {
|
||||||
mapRef.current.removeLayer('line-of-sight');
|
mapRef.current.removeLayer('line-of-sight');
|
||||||
mapRef.current.removeSource('line-of-sight');
|
mapRef.current.removeSource('line-of-sight');
|
||||||
@@ -44,6 +79,61 @@ const APP = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Update preview whenever point or direction changes
|
||||||
|
if (mapRef.current && mapRef.current.isStyleLoaded()) {
|
||||||
|
updatePreviewLine(selectedPoint, direction);
|
||||||
|
}
|
||||||
|
}, [selectedPoint, direction]);
|
||||||
|
|
||||||
|
const updatePreviewLine = (point, bearing) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.getSource('preview-line')) return;
|
||||||
|
|
||||||
|
// Generate 50 points along a 5000km great circle path for a smooth curve
|
||||||
|
const path = [];
|
||||||
|
const steps = 50;
|
||||||
|
const totalDistance = 5000;
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const dist = (totalDistance * i) / steps;
|
||||||
|
path.push(calculateDestination(point.lat, point.lon, bearing, dist));
|
||||||
|
}
|
||||||
|
|
||||||
|
map.getSource('preview-line').setData({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: path.map(p => [p.lon, p.lat])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to calculate destination point given start, bearing, and distance (km)
|
||||||
|
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||||
|
const R = 6371; // Earth's radius in km
|
||||||
|
const brng = (bearing * Math.PI) / 180;
|
||||||
|
const φ1 = (lat * Math.PI) / 180;
|
||||||
|
const λ1 = (lon * Math.PI) / 180;
|
||||||
|
const δ = distance / R;
|
||||||
|
|
||||||
|
const φ2 = Math.asin(
|
||||||
|
Math.sin(φ1) * Math.cos(δ) +
|
||||||
|
Math.cos(φ1) * Math.sin(δ) * Math.cos(brng)
|
||||||
|
);
|
||||||
|
const λ2 =
|
||||||
|
λ1 +
|
||||||
|
Math.atan2(
|
||||||
|
Math.sin(brng) * Math.sin(δ) * Math.cos(φ1),
|
||||||
|
Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: (φ2 * 180) / Math.PI,
|
||||||
|
lon: (((λ2 * 180) / Math.PI + 540) % 360) - 180
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Update map style when toggle changes
|
// Update map style when toggle changes
|
||||||
if (mapRef.current) {
|
if (mapRef.current) {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001/api';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'build',
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
esbuildOptions: {
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user