diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ba2a8ac..12ab36d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(npm test:*)", "Bash(npm run:*)", - "Bash(npx playwright:*)" + "Bash(npx playwright:*)", + "Bash(node -e ':*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 28bca26..80424ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Docker (preferred workflow) ```bash -docker-compose up --build # Start all services (frontend :3050, backend :3001, postgres) +docker-compose up --build # Start all services (frontend :3050, backend :3051, postgres) docker-compose down # Stop all services docker-compose logs -f # View logs @@ -25,7 +25,7 @@ docker-compose exec frontend npm run test:e2e ```bash # Backend -cd backend && npm run dev # Nodemon dev server on :3001 +cd backend && npm run dev # Nodemon dev server on :3051 # Frontend cd frontend && npm run start # Vite dev server on :3050 diff --git a/GEMINI.md b/GEMINI.md index e4618d5..4840b13 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -68,6 +68,17 @@ --- +## 📊 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. diff --git a/README.md b/README.md index b740c2d..b3299e9 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ docker-compose up --build ### 3. Access Application - **Frontend**: http://localhost:3050 -- **Backend API**: http://localhost:3001 +- **Backend API**: http://localhost:3051 - **Database**: localhost:5432 (PostgreSQL with PostGIS) ## 🎮 How to Use @@ -220,7 +220,7 @@ npm run dev ```bash # Find process using port lsof -i :3050 -lsof -i :3001 +lsof -i :3051 # Kill process kill -9 diff --git a/backend/Dockerfile b/backend/Dockerfile index fd98d81..9719083 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,7 +10,7 @@ RUN npm install COPY . . # Expose port -EXPOSE 3001 +EXPOSE 3051 # Start server CMD ["npm", "start"] diff --git a/backend/app/server.js b/backend/app/server.js index 6845e24..a7867dd 100644 --- a/backend/app/server.js +++ b/backend/app/server.js @@ -4,9 +4,8 @@ const { Pool } = require('pg'); require('dotenv').config(); const app = express(); -const PORT = process.env.PORT || 3001; +const PORT = process.env.PORT || 3051; -// Database connection pool const pool = new Pool({ connectionString: process.env.DATABASE_URL || 'postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight' }); @@ -14,7 +13,6 @@ const pool = new Pool({ app.use(cors()); app.use(express.json()); -// Helper to calculate destination point given start, bearing, and distance (km) const calculateDestination = (lat, lon, bearing, distance) => { const R = 6371; const brng = (bearing * Math.PI) / 180; @@ -39,41 +37,58 @@ const calculateDestination = (lat, lon, bearing, distance) => { }; }; -// Real API endpoint - uses PostGIS for spatial queries +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 startLat = parseFloat(lat) || 51.5074; const startLon = parseFloat(lon) || -0.1278; const bearing = parseInt(direction) || 0; const toleranceKm = parseInt(tolerance) || 50; - console.log(`Processing real request: lat=${startLat}, lon=${startLon}, bearing=${bearing}, tolerance=${toleranceKm}`); + console.log(`Processing request: lat=${startLat}, lon=${startLon}, bearing=${bearing}, tolerance=${toleranceKm}`); try { - // Generate path points for visualization and spatial query const pathPoints = []; - const totalDistance = 20000; - const steps = 80; // More steps for smoother speed transition - + 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)); } - // Batch check for 'over water' status for all path points - // We'll consider a point 'over water' if no city is within 500km - const waterChecks = await Promise.all(pathPoints.map(async (p) => { - const checkQuery = ` - SELECT EXISTS ( - SELECT 1 FROM cities - WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 500000) - LIMIT 1 - ) as has_land; - `; - const res = await pool.query(checkQuery, [p.lon, p.lat]); - return !res.rows[0].has_land; - })); + // 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, @@ -81,37 +96,67 @@ app.get('/api/line-of-sight', async (req, res) => { })); 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, - ST_Y(geom::geometry) as lat, - ST_X(geom::geometry) as lon, - ST_Distance(geom, (SELECT route FROM path)) / 1000 as distance_off_line_km, - ST_Distance(geom, (SELECT start_node FROM path)) / 1000 as distance_from_start_km, - ST_LineLocatePoint((SELECT route FROM path)::geometry, geom::geometry) as pos_on_line - FROM cities - WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000) - ORDER BY pos_on_line ASC - LIMIT 200; + 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}`); + res.json({ success: true, data: { start_point: { lat: startLat, lon: startLon }, direction: bearing, tolerance_km: toleranceKm, - conurbations: result.rows.map(row => ({ + conurbations: accepted.map(row => ({ ...row, name: row.name || 'Unknown', country: row.country || 'Unknown', @@ -121,13 +166,13 @@ app.get('/api/line-of-sight', async (req, res) => { line_coordinates: pathPointsWithWater } }); + 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) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); diff --git a/docker-compose.yml b/docker-compose.yml index ed2d685..7ff156f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,11 @@ -version: '3.8' - services: postgres: image: kartoza/postgis:latest - platform: linux/arm64 container_name: line-of-sight-db environment: - POSTGRES_DB=line_of_sight - POSTGRES_USER=line_of_sight - POSTGRES_PASS=line_of_sight_pass - - ALLOW_IP_RANGE=0.0.0.0/0 - ports: - - "5432:5432" volumes: - pgdata:/var/lib/postgresql - ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql @@ -26,11 +20,12 @@ services: context: ./backend dockerfile: Dockerfile container_name: line-of-sight-backend - ports: - - "3001:3001" + # Removed public port exposure for security; accessible via frontend proxy + # ports: + # - "3051:3051" environment: - DATABASE_URL=postgresql://line_of_sight:line_of_sight_pass@postgres:5432/line_of_sight - - PORT=3001 + - PORT=3051 depends_on: postgres: condition: service_healthy @@ -46,7 +41,7 @@ services: ports: - "3050:3050" environment: - - VITE_API_URL=http://localhost:3001/api + - VITE_API_URL=/api depends_on: - backend volumes: diff --git a/docker/data/cities.csv.gz b/docker/data/cities.csv.gz new file mode 100644 index 0000000..eb1f43e Binary files /dev/null and b/docker/data/cities.csv.gz differ diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 78f23c4..43790c4 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -12,8 +12,8 @@ COPY . . # Expose port EXPOSE 3050 -# Set environment variable for API URL -ENV VITE_API_URL=http://localhost:3001/api +# Set environment variable for API URL (relative to the frontend host) +ENV VITE_API_URL=/api # Start development server CMD ["npm", "start"] diff --git a/frontend/e2e/app.spec.js b/frontend/e2e/app.spec.js index cd14b93..526d282 100644 --- a/frontend/e2e/app.spec.js +++ b/frontend/e2e/app.spec.js @@ -33,6 +33,9 @@ class Map { 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 }; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 18ec4dc..b227a84 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,37 +5,131 @@ 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 ( + + {index + 1} + {city.name} + {(city.population / 1000).toFixed(0)}k + {city.distance_km}km + + ); +}); + const APP = () => { const mapContainerRef = useRef(null); const mapRef = useRef(null); const startMarkerRef = useRef(null); - const cityMarkersRef = useRef([]); const animationRef = useRef(null); const popupRef = useRef(null); + // 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(45); + 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('light'); // 'light' or 'dark' + 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(() => { - // Initialize MapLibre map - ONLY ONCE mapRef.current = new maplibregl.Map({ container: mapContainerRef.current, style: getMapStyle(mapStyle), - center: [-0.1278, 51.5074], // London + center: [-0.1278, 51.5074], zoom: 2, pitch: 0, - projection: 'globe' // Enable 3D Globe + projection: { type: 'globe' } }); mapRef.current.on('style.load', () => { - console.log('Map style loaded or changed'); setupMapLayers(); }); @@ -43,97 +137,210 @@ const APP = () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); if (mapRef.current) mapRef.current.remove(); }; - }, []); // Run only once + }, []); // eslint-disable-line react-hooks/exhaustive-deps const setupMapLayers = () => { const map = mapRef.current; if (!map) return; - // 1. Add atmosphere/sky effects - map.setSky({ - 'sky-color': '#199EF3', - 'sky-horizon-blend': 0.5, - 'horizon-color': '#ffffff', - 'horizon-fog-blend': 0.5, - 'fog-color': '#add8e6', - 'fog-ground-blend': 0.5 - }); - - // 2. Add terrain source for 3D effect - if (!map.getSource('terrain')) { - map.addSource('terrain', { - type: 'raster-dem', - tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'], - encoding: 'terrarium', - tileSize: 256, - maxzoom: 15 - }); - } - map.setTerrain({ source: 'terrain', exaggeration: 1.5 }); + const isGlobe = mapProjectionRef.current === 'globe'; - // 3. Initialize start marker + // 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([selectedPoint.lon, selectedPoint.lat]) + .setLngLat([currentPoint.lon, currentPoint.lat]) .addTo(map); - // 4. Initialize preview line source and layer + // 4. Preview line source and layer if (!map.getSource('preview-line')) { map.addSource('preview-line', { type: 'geojson', - data: { - type: 'Feature', - geometry: { - type: 'LineString', - coordinates: [] - } - } + 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] - } + paint: { 'line-color': '#FF6B6B', 'line-width': 2, 'line-dasharray': [2, 2] } }); } - // 5. Restore results line and city markers if they existed - if (lineOfSightData) { - renderLineOnMap(lineOfSightData); - // Hide preview if results are shown + // 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(selectedPoint, direction); + updatePreviewLine(currentPoint, currentDirection); } }; - // Separate effect for the click listener that respects isLocked + // --- 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) => { - // Don't allow moving start point if locked 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]); } - - // Clear previous final results when moving start point - clearCityMarkers(); - if (mapRef.current.getSource('line-of-sight')) { - mapRef.current.removeLayer('line-of-sight'); - mapRef.current.removeSource('line-of-sight'); + 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'); } }; @@ -141,7 +348,24 @@ const APP = () => { return () => { if (mapRef.current) mapRef.current.off('click', handleClick); }; - }, [isLocked]); + }, [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) { @@ -149,44 +373,52 @@ const APP = () => { 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); - clearCityMarkers(); - + setApiError(null); + syncCitiesToMap([]); if (mapRef.current) { - if (mapRef.current.getSource('line-of-sight')) { - mapRef.current.removeLayer('line-of-sight'); - mapRef.current.removeSource('line-of-sight'); + 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'); } - // Re-enable preview line if it was hidden - if (mapRef.current.getLayer('preview-line')) { - mapRef.current.setLayoutProperty('preview-line', 'visibility', 'visible'); + if (map.getLayer('preview-line')) { + map.setLayoutProperty('preview-line', 'visibility', 'visible'); } - - // Reset map pitch and zoom - mapRef.current.flyTo({ - center: [selectedPoint.lon, selectedPoint.lat], - zoom: 3, - pitch: 0, - bearing: 0 - }); + map.flyTo({ center: [selectedPoint.lon, selectedPoint.lat], zoom: 3, pitch: 0, bearing: 0 }); } }; - const clearCityMarkers = () => { - if (cityMarkersRef.current) { - cityMarkersRef.current.forEach(marker => marker.remove()); - cityMarkersRef.current = []; - } + const 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' }); @@ -198,13 +430,10 @@ const APP = () => { 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 = ` `; - - popupRef.current = new maplibregl.Popup({ - closeButton: true, + popupRef.current = new maplibregl.Popup({ + closeButton: true, closeOnClick: false, maxWidth: '300px', offset: [0, -20] @@ -225,15 +453,12 @@ const APP = () => { .setLngLat([city.lon, city.lat]) .setDOMContent(popupNode) .addTo(mapRef.current); - - popupRef.current.on('close', () => { - setSelectedCity(null); - }); + popupRef.current.on('close', () => setSelectedCity(null)); }; const startFlyOver = () => { if (!lineOfSightData || !mapRef.current) return; - + if (isPlaying) { if (animationRef.current) cancelAnimationFrame(animationRef.current); setIsPlaying(false); @@ -242,37 +467,42 @@ const APP = () => { } setIsPlaying(true); - setSelectedCity(null); + // 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); - const startTime = performance.now(); - let lastNearestCityId = null; - let lastFetchedDist = 0; - let currentProgress = 0; + 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; // 100km per second - - const pathPoints = lineOfSightData.line_coordinates; + 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; - // Predictive land detection (look ahead 2000km) let futureIsOverWater = true; const lookAheadDistance = 2000; const lookAheadSteps = 10; @@ -290,7 +520,6 @@ const APP = () => { 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) { @@ -298,39 +527,42 @@ const APP = () => { } frameCount++; - if (frameCount % 10 === 0) { - setFlightSpeed(currentSpeedMultiplier); - } + 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: bearing + 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 - ); - + const response = await apiService.getLineOfSight(eye[1], eye[0], bearing, tolerance); if (response.data.success) { const newCities = response.data.data.conurbations; setLineOfSightData(prev => { @@ -344,7 +576,7 @@ const APP = () => { ...prev, conurbations: [...prev.conurbations, ...adjustedCities].sort((a, b) => a.distance_km - b.distance_km) }; - addMarkersToMap(adjustedCities, prev.conurbations.length); + syncCitiesToMap(updatedData.conurbations); return updatedData; }); } @@ -353,122 +585,87 @@ const APP = () => { } } - const nearestCity = lineOfSightData.conurbations.find(city => - Math.abs(city.distance_km - currentProgress) < 100 - ); - - if (nearestCity && nearestCity.id !== lastNearestCityId) { - lastNearestCityId = nearestCity.id; - setSelectedCity(nearestCity); - showCityPopup(nearestCity); - } - + flightProgressRef.current = currentProgress; animationRef.current = requestAnimationFrame(animate); } - + animationRef.current = requestAnimationFrame(animate); }; - const addMarkersToMap = (cities, startIndex = 0) => { - const map = mapRef.current; - if (!map) return; - - cities.forEach((city, index) => { - const displayIndex = startIndex + index + 1; - - const el = document.createElement('div'); - el.className = 'city-marker'; - el.innerHTML = ` -
${displayIndex}
-
-
${city.name}
-
${(city.population / 1000).toFixed(0)}k
- `; - - const marker = new maplibregl.Marker(el) - .setLngLat([city.lon, city.lat]) - .addTo(map); - - marker.getElement().addEventListener('click', (e) => { - e.stopPropagation(); - setSelectedCity(city); - showCityPopup(city); - }); - - cityMarkersRef.current.push(marker); - }); - }; - const renderLineOnMap = (data) => { const map = mapRef.current; if (!map) return; - clearCityMarkers(); + // 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: { - type: 'Feature', - properties: {}, - geometry: { - type: 'LineString', - coordinates: data.line_coordinates.map(coord => [coord.lon, coord.lat]) - } - } + 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: { - 'line-join': 'round', - 'line-cap': 'round' - }, - paint: { - 'line-color': '#FF6B6B', - 'line-width': 4, - 'line-opacity': 0.8 + '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); - addMarkersToMap(data.conurbations); - - const bounds = new maplibregl.LngLatBounds(); - data.line_coordinates.forEach(coord => { - bounds.extend([coord.lon, coord.lat]); + // 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 }); - map.fitBounds(bounds, { padding: 50 }); }; - const updatePreviewLine = (point, bearing) => { + const updatePreviewLine = (point, brng) => { const map = mapRef.current; if (!map || !map.getSource('preview-line')) return; const path = []; - const steps = 50; - const totalDistance = 20000; + 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, bearing, dist)); + path.push(calculateDestination(point.lat, point.lon, brng, dist)); } - - map.getSource('preview-line').setData({ - type: 'Feature', - geometry: { - type: 'LineString', - coordinates: path.map(p => [p.lon, p.lat]) - } - }); + + map.getSource('preview-line').setData( + buildLineGeoJSON(path) + ); }; const calculateDestination = (lat, lon, bearing, distance) => { - const R = 6371; + const R = 6371; const brng = (bearing * Math.PI) / 180; const φ1 = (lat * Math.PI) / 180; const λ1 = (lon * Math.PI) / 180; @@ -491,12 +688,6 @@ const APP = () => { }; }; - useEffect(() => { - if (mapRef.current) { - mapRef.current.setStyle(getMapStyle(mapStyle)); - } - }, [mapStyle]); - const getMapStyle = (style) => { if (style === 'satellite') { return { @@ -509,57 +700,98 @@ const APP = () => { 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 - } - ] + layers: [{ + id: 'satellite-layer', + type: 'raster', + source: 'esri-satellite', + minzoom: 0, + maxzoom: 20 + }] }; } - return style === 'dark' - ? 'https://demotiles.maplibre.org/style.json' - : 'https://demotiles.maplibre.org/style.json'; + 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, + selectedPoint.lat, + selectedPoint.lon, + direction, tolerance ); - setLineOfSightData(response.data.data); - setIsLocked(true); - + 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 (
+ {!isLocked && ( + + )}
- +

Line of Sight Settings

- +
{selectedPoint.lat.toFixed(4)}, {selectedPoint.lon.toFixed(4)} @@ -567,11 +799,11 @@ const APP = () => {
- setDirection(parseInt(e.target.value))} /> @@ -580,9 +812,9 @@ const APP = () => {
- setTolerance(parseInt(e.target.value))} min="10" @@ -593,41 +825,72 @@ const APP = () => {
- - - + + +
+
+ +
+ {PROJECTIONS.map(p => ( + + ))} +
+
+ + {apiError && ( +
+ ⚠️ {apiError} +
+ )} + {!isLocked ? ( - ) : ( <> - - + +
+ @@ -636,33 +899,40 @@ const APP = () => { {lineOfSightData && (
-

Conurbations Found ({lineOfSightData.conurbations.length})

-

Click a city for details

+

Cities Along Route ({lineOfSightData.conurbations.length})

+ {isPlaying && currentCityIndex >= 0 && ( +
+ Now passing: {lineOfSightData.conurbations[currentCityIndex]?.name} +
+ )} +

{isPlaying ? 'Click a city to jump to it' : 'Click a city for details'}

- + {lineOfSightData.conurbations.map((city, index) => ( - { setSelectedCity(city); showCityPopup(city); mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 }); + if (isPlaying) { + seekRef.current = city.distance_km; + } }} - className={selectedCity?.id === city.id ? 'selected-row' : ''} - > - - - - - + /> ))}
# CityPopulationPop. Dist.
{index + 1}{city.name}{(city.population / 1000).toFixed(0)}k{city.distance_km}km
diff --git a/frontend/src/__tests__/App.test.jsx b/frontend/src/__tests__/App.test.jsx index cd812e3..3b7da5d 100644 --- a/frontend/src/__tests__/App.test.jsx +++ b/frontend/src/__tests__/App.test.jsx @@ -23,6 +23,9 @@ vi.mock('maplibre-gl', () => { isStyleLoaded: vi.fn(() => true), setSky: vi.fn(), setTerrain: vi.fn(), + setProjection: vi.fn(), + hasImage: vi.fn(() => false), + addImage: vi.fn(), }; return { diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 18128a6..3d9346b 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,13 +1,10 @@ import axios from 'axios'; -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; const api = axios.create({ baseURL: API_BASE_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json' - } + timeout: 30000 }); export const getLineOfSight = async (lat, lon, direction, tolerance) => { diff --git a/frontend/src/styles/App.css b/frontend/src/styles/App.css index 4d635e2..9f869b1 100644 --- a/frontend/src/styles/App.css +++ b/frontend/src/styles/App.css @@ -13,6 +13,30 @@ 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 { width: 350px; background: white; @@ -176,6 +200,7 @@ width: 100%; border-collapse: collapse; font-size: 13px; + table-layout: fixed; } .results-panel th { @@ -189,6 +214,13 @@ padding: 8px 4px; 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) { @@ -322,63 +354,6 @@ margin-bottom: 8px; } -.city-marker { - display: flex; - flex-direction: column; - align-items: center; - font-family: sans-serif; - z-index: 100; - cursor: pointer; - pointer-events: auto !important; -} - -.marker-number { - background: #2c3e50; - color: white; - width: 18px; - height: 18px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: bold; - margin-bottom: -4px; - border: 1px solid white; - z-index: 2; -} - -.marker-dot { - width: 12px; - height: 12px; - background: #e74c3c; - border: 2px solid white; - border-radius: 50%; - box-shadow: 0 2px 4px rgba(0,0,0,0.3); - z-index: 1; -} - -.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; -} - .map-info-popup { padding: 5px; font-family: sans-serif; @@ -411,17 +386,48 @@ 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; + font-size: 11px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.projection-buttons button:hover { + background: #e9ecef; +} + +.projection-buttons button.active-style { + background: #34495e; + color: white; + border-color: #2c3e50; +} + @media (max-width: 768px) { .app-container { flex-direction: column; } - + .controls { width: 100%; max-height: 40vh; order: -1; } - + .map-container { height: 60vh; } @@ -434,3 +440,98 @@ 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; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c4cc50e..c8d6f65 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -12,6 +12,13 @@ export default defineConfig({ server: { port: 3050, host: '0.0.0.0', + allowedHosts: true, + proxy: { + '/api': { + target: 'http://backend:3051', + changeOrigin: true + } + } }, build: { outDir: 'build', diff --git a/import-cities.sh b/import-cities.sh new file mode 100755 index 0000000..a3956bc --- /dev/null +++ b/import-cities.sh @@ -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 <