commit b40116b56f79fa5616bab3462ad0022476251fb0 Author: Agent Zero Date: Mon Mar 16 15:27:35 2026 +0000 Initial commit: MVP scaffolding for Line of Sight application - React frontend with MapLibre integration - Node.js Express backend with dummy API - Docker containerization (PostgreSQL+PostGIS, backend, frontend) - Interactive map with direction selector - 20 mock conurbations data - Full project structure ready for PR workflow diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4ec016 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ +venv/ +__pycache__/ +*.pyc +*.pyo + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build outputs +dist/ +build/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Docker +*.pid +*.seed + +# Testing +coverage/ +.nyc_output/ + +# Project specific +.a0proj/ +tmp/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f9b2d62 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:18-alpine + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm install + +# Copy source code +COPY . . + +# Expose port +EXPOSE 3001 + +# Start server +CMD ["npm", "start"] diff --git a/backend/app/server.js b/backend/app/server.js new file mode 100644 index 0000000..41c5b04 --- /dev/null +++ b/backend/app/server.js @@ -0,0 +1,73 @@ +const express = require('express'); +const cors = require('cors'); +require('dotenv').config(); + +const app = express(); +const PORT = process.env.PORT || 3001; + +app.use(cors()); +app.use(express.json()); + +// Mock conurbation data for MVP +const MOCK_CONURBATIONS = [ + { id: 1, name: "London", population: 9000000, distance_km: 0, lat: 51.5074, lon: -0.1278 }, + { id: 2, name: "Paris", population: 2161000, distance_km: 344, lat: 48.8566, lon: 2.3522 }, + { id: 3, name: "Berlin", population: 3644000, distance_km: 878, lat: 52.5200, lon: 13.4050 }, + { id: 4, name: "Warsaw", population: 1793000, distance_km: 1200, lat: 52.2297, lon: 21.0122 }, + { id: 5, name: "Moscow", population: 12506000, distance_km: 2063, lat: 55.7558, lon: 37.6173 }, + { id: 6, name: "Kazan", population: 1257000, distance_km: 2850, lat: 55.7897, lon: 49.1219 }, + { id: 7, name: "Almaty", population: 2000000, distance_km: 3900, lat: 43.2220, lon: 76.8512 }, + { id: 8, name: "Urumqi", population: 3500000, distance_km: 4500, lat: 43.8256, lon: 87.6168 }, + { id: 9, name: "Lahore", population: 11126000, distance_km: 5400, lat: 31.5204, lon: 74.3587 }, + { id: 10, name: "New Delhi", population: 29399000, distance_km: 5800, lat: 28.6139, lon: 77.2090 }, + { id: 11, name: "Dhaka", population: 21006000, distance_km: 6200, lat: 23.8103, lon: 90.4125 }, + { id: 12, name: "Chennai", population: 10971000, distance_km: 6500, lat: 13.0827, lon: 80.2707 }, + { id: 13, name: "Bangkok", population: 10539000, distance_km: 7200, lat: 13.7563, lon: 100.5018 }, + { id: 14, name: "Jakarta", population: 10562000, distance_km: 8100, lat: -6.2088, lon: 106.8456 }, + { id: 15, name: "Singapore", population: 5686000, distance_km: 8300, lat: 1.3521, lon: 103.8198 }, + { id: 16, name: "Manila", population: 17801000, distance_km: 8700, lat: 14.5995, lon: 120.9842 }, + { id: 17, name: "Tokyo", population: 37400000, distance_km: 9500, lat: 35.6762, lon: 139.6503 }, + { id: 18, name: "Seoul", population: 9720000, distance_km: 9200, lat: 37.5665, lon: 126.9780 }, + { id: 19, name: "Beijing", population: 21540000, distance_km: 8900, lat: 39.9042, lon: 116.4074 }, + { id: 20, name: "Shanghai", population: 27058000, distance_km: 9000, lat: 31.2304, lon: 121.4737 } +]; + +// Mock API endpoint - returns dummy conurbations based on input coordinates +app.get('/api/line-of-sight', (req, res) => { + const { lat, lon, direction, tolerance } = req.query; + + console.log(`Received request: lat=${lat}, lon=${lon}, direction=${direction}, tolerance=${tolerance}`); + + // Return mock data for MVP + res.json({ + success: true, + data: { + start_point: { lat: parseFloat(lat) || 51.5074, lon: parseFloat(lon) || -0.1278 }, + direction: parseInt(direction) || 45, + tolerance_km: parseInt(tolerance) || 50, + conurbations: MOCK_CONURBATIONS.slice(0, 20), + line_coordinates: [ + { lat: 51.5074, lon: -0.1278 }, + { lat: 48.8566, lon: 2.3522 }, + { lat: 52.5200, lon: 13.4050 }, + { lat: 55.7558, lon: 37.6173 }, + { lat: 43.2220, lon: 76.8512 }, + { lat: 28.6139, lon: 77.2090 }, + { lat: 13.7563, lon: 100.5018 }, + { lat: -6.2088, lon: 106.8456 }, + { lat: 35.6762, lon: 139.6503 }, + { lat: 51.5074, lon: -0.1278 } // Complete the circle + ] + }, + message: "Mock data returned for MVP - Real geospatial calculations coming soon" + }); +}); + +// Health check endpoint +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Line of Sight Backend running on port ${PORT}`); +}); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..27136e1 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,21 @@ +{ + "name": "line-of-sight-backend", + "version": "1.0.0", + "description": "Line of Sight geospatial API backend", + "main": "app/server.js", + "scripts": { + "start": "node app/server.js", + "dev": "nodemon app/server.js", + "test": "jest" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "pg": "^8.11.3", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "jest": "^29.7.0" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6ff045a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +version: '3.8' + +services: + postgres: + image: postgis/postgis:15-3.3-alpine + container_name: line-of-sight-db + environment: + POSTGRES_DB: line_of_sight + POSTGRES_USER: line_of_sight + POSTGRES_PASSWORD: line_of_sight_pass + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U line_of_sight"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: line-of-sight-backend + ports: + - "3001:3001" + environment: + - DATABASE_URL=postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight + - PORT=3001 + depends_on: + postgres: + condition: service_healthy + volumes: + - ./backend:/app + - /app/node_modules + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: line-of-sight-frontend + ports: + - "3000:3000" + environment: + - REACT_APP_API_URL=http://localhost:3001/api + depends_on: + - backend + volumes: + - ./frontend:/app + - /app/node_modules + +volumes: + pgdata: diff --git a/docker/init.sql b/docker/init.sql new file mode 100644 index 0000000..50f63d6 --- /dev/null +++ b/docker/init.sql @@ -0,0 +1,27 @@ +-- Initialize PostGIS +CREATE EXTENSION IF NOT EXISTS postgis; + +-- Create cities table for conurbation data +CREATE TABLE IF NOT EXISTS cities ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + population INTEGER, + country VARCHAR(100), + geom GEOGRAPHY(POINT, 4326) NOT NULL +); + +-- Create spatial index +CREATE INDEX IF NOT EXISTS idx_cities_geom ON cities USING GIST(geom); + +-- Insert some seed data for testing +INSERT INTO cities (name, population, country, geom) VALUES +('London', 9000000, 'United Kingdom', ST_GeomFromText('POINT(-0.1278 51.5074)', 4326)), +('Paris', 2161000, 'France', ST_GeomFromText('POINT(2.3522 48.8566)', 4326)), +('Berlin', 3644000, 'Germany', ST_GeomFromText('POINT(13.4050 52.5200)', 4326)), +('Moscow', 12506000, 'Russia', ST_GeomFromText('POINT(37.6173 55.7558)', 4326)), +('Tokyo', 37400000, 'Japan', ST_GeomFromText('POINT(139.6503 35.6762)', 4326)), +('New York', 8336000, 'USA', ST_GeomFromText('POINT(-74.0060 40.7128)', 4326)), +('Beijing', 21540000, 'China', ST_GeomFromText('POINT(116.4074 39.9042)', 4326)), +('Mumbai', 20411000, 'India', ST_GeomFromText('POINT(72.8777 19.0760)', 4326)), +('São Paulo', 12325000, 'Brazil', ST_GeomFromText('POINT(-46.6333 -23.5505)', 4326)), +('Cairo', 9500000, 'Egypt', ST_GeomFromText('POINT(31.2357 30.0444)', 4326)); diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..690dc77 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,19 @@ +FROM node:18-alpine + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm install + +# Copy source code +COPY . . + +# Expose port +EXPOSE 3000 + +# Set environment variable for API URL +ENV REACT_APP_API_URL=http://localhost:3001/api + +# Start development server +CMD ["npm", "start"] diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a740c42 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "line-of-sight-frontend", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1", + "maplibre-gl": "^3.6.2", + "axios": "^1.6.2" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "browserslist": { + "production": [">0.2%", "not dead", "not op_mini all"], + "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..7471e6f --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,15 @@ + + + + + + Line of Sight - Interactive Map + + + +
+ + diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..0b7a2e6 --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,243 @@ +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 = ` +
+
${city.name}
+
${(city.population / 1000000).toFixed(1)}M
+ `; + + // Add marker to map + new maplibregl.Marker(el) + .setLngLat([city.lon, city.lat]) + .setPopup(new maplibregl.Popup().setHTML( + `${city.name}
Population: ${city.population.toLocaleString()}
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 ( +
+
+ +
+
+

Line of Sight Settings

+ +
+ + {selectedPoint.lat.toFixed(4)}, {selectedPoint.lon.toFixed(4)} +
+ +
+ + setDirection(parseInt(e.target.value))} + /> + {direction}° +
+ +
+ + setTolerance(parseInt(e.target.value))} + min="10" + max="200" + /> + {tolerance} km +
+ +
+ + + +
+ + +
+ + {lineOfSightData && ( +
+

Conurbations Found ({lineOfSightData.conurbations.length})

+ + + + + + + + + + + {lineOfSightData.conurbations.slice(0, 10).map((city, index) => ( + + + + + + + ))} + +
#CityPopulationDistance
{index + 1}{city.name}{(city.population / 1000000).toFixed(1)}M{city.distance_km} km
+ {lineOfSightData.conurbations.length > 10 && ( +

... and {lineOfSightData.conurbations.length - 10} more cities

+ )} +
+ )} + +
+

How to Use

+
    +
  1. Click anywhere on the map to select a starting point
  2. +
  3. Adjust the direction using the slider (0-360°)
  4. +
  5. Set your fuzziness tolerance (how close cities must be to the line)
  6. +
  7. Click "Show Line of Sight" to visualize the path and cities
  8. +
+
+
+
+ ); +}; + +export default APP; diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 0000000..3f03a17 --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles/index.css'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..7fcd8b5 --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,25 @@ +import axios from 'axios'; + +const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001/api'; + +const api = axios.create({ + baseURL: API_BASE_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json' + } +}); + +export const getLineOfSight = async (lat, lon, direction, tolerance) => { + const response = await api.get('/line-of-sight', { + params: { lat, lon, direction, tolerance } + }); + return response; +}; + +export const healthCheck = async () => { + const response = await api.get('/health'); + return response; +}; + +export default { getLineOfSight, healthCheck }; diff --git a/frontend/src/styles/App.css b/frontend/src/styles/App.css new file mode 100644 index 0000000..8a8bac3 --- /dev/null +++ b/frontend/src/styles/App.css @@ -0,0 +1,226 @@ +.app-container { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +.map-container { + flex: 1; + height: 100%; + position: relative; + z-index: 1; +} + +.controls { + width: 350px; + background: white; + padding: 20px; + box-shadow: -2px 0 10px rgba(0,0,0,0.1); + overflow-y: auto; + z-index: 2; +} + +.control-group { + background: #f8f9fa; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; +} + +.control-group h3 { + margin-top: 0; + color: #2c3e50; + font-size: 16px; + margin-bottom: 15px; +} + +.setting-row { + display: flex; + align-items: center; + margin-bottom: 12px; + gap: 10px; +} + +.setting-row label { + flex: 1; + font-size: 14px; + color: #555; +} + +.setting-row input[type="range"] { + flex: 2; +} + +.setting-row input[type="number"] { + width: 80px; + padding: 5px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.setting-row span { + font-weight: bold; + color: #2c3e50; +} + +.setting-row button { + padding: 6px 12px; + margin: 0 5px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.setting-row button:hover { + background: #e9ecef; +} + +.action-btn { + width: 100%; + padding: 12px; + background: #4CAF50; + color: white; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: background 0.3s; + margin-top: 10px; +} + +.action-btn:hover:not(:disabled) { + background: #45a049; +} + +.action-btn:disabled { + background: #ccc; + cursor: not-allowed; +} + +.results-panel { + background: #f8f9fa; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; +} + +.results-panel h3 { + margin-top: 0; + color: #2c3e50; + font-size: 16px; + margin-bottom: 15px; +} + +.results-panel table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.results-panel th { + background: #34495e; + color: white; + padding: 10px; + text-align: left; +} + +.results-panel td { + padding: 8px; + border-bottom: 1px solid #ddd; +} + +.results-panel tr:nth-child(even) { + background: #f8f9fa; +} + +.results-panel tr:hover { + background: #e9ecef; +} + +.more-info { + color: #666; + font-size: 12px; + text-align: center; + margin-top: 10px; +} + +.instructions { + background: #fff3cd; + padding: 15px; + border-radius: 8px; + border-left: 4px solid #ffc107; +} + +.instructions h3 { + margin-top: 0; + color: #856404; + font-size: 16px; +} + +.instructions ol { + margin: 0; + padding-left: 20px; + font-size: 13px; + color: #856404; +} + +.instructions li { + margin-bottom: 8px; +} + +.city-marker { + display: flex; + flex-direction: column; + align-items: center; + font-family: sans-serif; +} + +.marker-dot { + width: 12px; + height: 12px; + background: #e74c3c; + border: 2px solid white; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); +} + +.marker-label { + background: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: bold; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + margin-top: 2px; + white-space: nowrap; +} + +.marker-pop { + background: #34495e; + color: white; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + margin-top: 2px; + white-space: nowrap; +} + +@media (max-width: 768px) { + .app-container { + flex-direction: column; + } + + .controls { + width: 100%; + max-height: 40vh; + order: -1; + } + + .map-container { + height: 60vh; + } +} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..aaf8156 --- /dev/null +++ b/frontend/src/styles/index.css @@ -0,0 +1,14 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; +} + +#root { + height: 100vh; +}