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
This commit is contained in:
Agent Zero
2026-03-16 15:27:35 +00:00
commit b40116b56f
14 changed files with 808 additions and 0 deletions
+41
View File
@@ -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/
+16
View File
@@ -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"]
+73
View File
@@ -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}`);
});
+21
View File
@@ -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"
}
}
+55
View File
@@ -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:
+27
View File
@@ -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));
+19
View File
@@ -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"]
+22
View File
@@ -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"]
}
}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Line of Sight - Interactive Map</title>
<style>
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
#root { height: 100vh; }
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>
+243
View File
@@ -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 = `
<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;
+11
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);
+25
View File
@@ -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 };
+226
View File
@@ -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;
}
}
+14
View File
@@ -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;
}