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/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 903cd2b..5500846 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,40 +37,46 @@ 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 = 40074; // Full Earth circumference (km) + 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 + // Batch water check: a point is "over water" if no city is within 500 km const waterChecks = await Promise.all(pathPoints.map(async (p) => { const checkQuery = ` SELECT EXISTS ( - SELECT 1 FROM cities + 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; + const r = await pool.query(checkQuery, [p.lon, p.lat]); + return !r.rows[0].has_land; })); const pathPointsWithWater = pathPoints.map((p, i) => ({ @@ -81,37 +85,56 @@ 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)::geometry, geom::geometry) as pos_on_line, + FLOOR(ST_Distance(geom, (SELECT start_node FROM path)) / 1000 / 100)::int as bin_100km + FROM cities + WHERE ST_DWithin(geom, (SELECT route FROM path), $2 * 1000) + ), + ranked AS ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY bin_100km 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 <= 5 + ORDER BY pos_on_line ASC; `; const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]); - + + // Greedy 30 km deduplication: sort by population desc, accept a city only if + // it's at least 30 km from every already-accepted city. + const byPopulation = [...result.rows].sort((a, b) => (b.population || 0) - (a.population || 0)); + const accepted = []; + for (const city of byPopulation) { + const tooClose = accepted.some(s => haversineKm(s.lat, s.lon, city.lat, city.lon) < 30); + if (!tooClose) accepted.push(city); + } + accepted.sort((a, b) => a.pos_on_line - b.pos_on_line); + 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', @@ -127,7 +150,6 @@ app.get('/api/line-of-sight', async (req, res) => { } }); -// 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..78e0ae2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,10 +27,10 @@ services: dockerfile: Dockerfile container_name: line-of-sight-backend ports: - - "3001:3001" + - "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 +46,7 @@ services: ports: - "3050:3050" environment: - - VITE_API_URL=http://localhost:3001/api + - VITE_API_URL=http://localhost:3051/api depends_on: - backend volumes: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 78f23c4..7d44a8d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,7 +13,7 @@ COPY . . EXPOSE 3050 # Set environment variable for API URL -ENV VITE_API_URL=http://localhost:3001/api +ENV VITE_API_URL=http://localhost:3051/api # Start development server CMD ["npm", "start"] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f7f24ad..80a0429 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,38 +12,47 @@ const APP = () => { const cityMarkersRef = useRef([]); const animationRef = useRef(null); const popupRef = useRef(null); - // Ref mirrors mapProjection state so stale closures (style.load) read the current value + // 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 [flightSpeed, setFlightSpeed] = useState(1.0); - const [mapStyle, setMapStyle] = useState('light'); + 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 ref in sync with state + // 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(() => { - mapProjectionRef.current = mapProjection; - }, [mapProjection]); + if (isPlaying && currentCityIndex >= 0) { + const row = document.getElementById(`city-row-${currentCityIndex}`); + if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [currentCityIndex, isPlaying]); // --- Helpers --- // Build GeoJSON for the line, splitting at the antimeridian for flat projections - const buildLineGeoJSON = (lineCoords, projection) => { + const buildLineGeoJSON = (lineCoords) => { const coords = lineCoords.map(c => [c.lon, c.lat]); - if (projection === 'globe') { - return { - type: 'Feature', - properties: {}, - geometry: { type: 'LineString', coordinates: coords } - }; - } // Split segments wherever consecutive points cross the antimeridian (±180°) const segments = []; let current = [coords[0]]; @@ -80,7 +89,8 @@ const APP = () => { ctx.lineTo(size * 0.78, size * 0.5); ctx.lineTo(size * 0.2, size * 0.78); ctx.stroke(); - map.addImage('arrow-icon', canvas); + // MapLibre v5 requires ImageData, not a raw canvas element + map.addImage('arrow-icon', ctx.getImageData(0, 0, size, size)); }; // --- Map initialisation (runs once) --- @@ -92,7 +102,7 @@ const APP = () => { center: [-0.1278, 51.5074], zoom: 2, pitch: 0, - projection: 'globe' + projection: { type: 'globe' } }); mapRef.current.on('style.load', () => { @@ -136,10 +146,15 @@ const APP = () => { 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. Preview line source and layer @@ -157,13 +172,13 @@ const APP = () => { } // 5. Restore results line and markers if they existed - if (lineOfSightData) { - renderLineOnMap(lineOfSightData); + if (currentLineData) { + renderLineOnMap(currentLineData); if (map.getLayer('preview-line')) { map.setLayoutProperty('preview-line', 'visibility', 'none'); } } else { - updatePreviewLine(selectedPoint, direction); + updatePreviewLine(currentPoint, currentDirection); } }; @@ -173,29 +188,38 @@ const APP = () => { const map = mapRef.current; if (!map) return; - map.setProjection(mapProjection); + const applyProjection = () => { + map.setProjection({ type: mapProjection }); - if (mapProjection === 'globe') { - 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 }); + if (mapProjection === 'globe') { + 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 { + map.setTerrain(null); } - } else { - map.setTerrain(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, mapProjection) - ); + // 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 @@ -207,6 +231,8 @@ const APP = () => { const handleClick = (e) => { if (isLocked) return; const { lng, lat } = e.lngLat; + // Update ref immediately (state update is async) + selectedPointRef.current = { lat, lon: lng }; setSelectedPoint({ lat, lon: lng }); if (startMarkerRef.current) { startMarkerRef.current.setLngLat([lng, lat]); @@ -218,6 +244,9 @@ const APP = () => { map.removeLayer('line-of-sight'); map.removeSource('line-of-sight'); } + if (map.getLayer('preview-line')) { + map.setLayoutProperty('preview-line', 'visibility', 'visible'); + } }; mapRef.current.on('click', handleClick); @@ -322,20 +351,23 @@ const APP = () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); setIsPlaying(false); setFlightSpeed(1.0); + setCurrentCityIndex(-1); + currentCityIndexRef.current = -1; return; } setIsPlaying(true); - setSelectedCity(null); + setCurrentCityIndex(-1); + currentCityIndexRef.current = -1; if (popupRef.current) popupRef.current.remove(); const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]); const route = turf.lineString(coordinates); const routeDistance = turf.length(route); - let lastNearestCityId = null; - let lastFetchedDist = 0; - let currentProgress = 0; + let lastFetchedDist = flightProgressRef.current; + let currentProgress = flightProgressRef.current; + flightProgressRef.current = 0; let lastTimestamp = performance.now(); let currentSpeedMultiplier = 1.0; let frameCount = 0; @@ -343,18 +375,25 @@ const APP = () => { 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; // 100 km/s - 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 2000 km) let futureIsOverWater = true; const lookAheadDistance = 2000; const lookAheadSteps = 10; @@ -387,6 +426,8 @@ const APP = () => { if (phase >= 1) { setIsPlaying(false); setFlightSpeed(1.0); + setCurrentCityIndex(-1); + currentCityIndexRef.current = -1; return; } @@ -396,6 +437,20 @@ const APP = () => { 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); + showCityPopup(cities[nearestIdx]); + } + if (currentProgress - lastFetchedDist > 2000) { lastFetchedDist = currentProgress; try { @@ -422,15 +477,6 @@ 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); - } - animationRef.current = requestAnimationFrame(animate); } @@ -477,7 +523,7 @@ const APP = () => { map.addSource('line-of-sight', { type: 'geojson', - data: buildLineGeoJSON(data.line_coordinates, projection) + data: buildLineGeoJSON(data.line_coordinates) }); map.addLayer({ @@ -527,10 +573,9 @@ const APP = () => { 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) => { @@ -578,6 +623,10 @@ const APP = () => { }] }; } + if (style === 'map') { + return 'https://tiles.openfreemap.org/styles/liberty'; + } + // 'basic' — clean MapLibre demo tiles, no labels return 'https://demotiles.maplibre.org/style.json'; }; @@ -607,9 +656,7 @@ const APP = () => { const PROJECTIONS = [ { id: 'globe', label: 'Globe' }, { id: 'mercator', label: 'Mercator' }, - { id: 'equalEarth', label: 'Equal Earth' }, - { id: 'naturalEarth', label: 'Natural Earth' }, - { id: 'winkelTripel', label: 'Winkel Tripel' } + { id: 'vertical-perspective', label: 'Perspective' } ]; return ( @@ -653,8 +700,8 @@ const APP = () => {
- - + +
@@ -695,34 +742,53 @@ 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 }); - }} - className={selectedCity?.id === city.id ? 'selected-row' : ''} - > - - - - - - ))} + {lineOfSightData.conurbations.map((city, index) => { + 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 (selectedCity?.id === city.id) { + rowClass = 'selected-row'; + } + return ( + { + setSelectedCity(city); + showCityPopup(city); + mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 }); + if (isPlaying) { + seekRef.current = city.distance_km; + } + }} + className={rowClass} + > + + + + + + ); + })}
# CityPopulationPop. Dist.
{index + 1}{city.name}{(city.population / 1000).toFixed(0)}k{city.distance_km}km
{index + 1}{city.name}{(city.population / 1000).toFixed(0)}k{city.distance_km}km
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 18128a6..f85dba8 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,6 +1,6 @@ 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 || 'http://localhost:3051/api'; const api = axios.create({ baseURL: API_BASE_URL, diff --git a/frontend/src/styles/App.css b/frontend/src/styles/App.css index 08aac9b..2aa8b33 100644 --- a/frontend/src/styles/App.css +++ b/frontend/src/styles/App.css @@ -446,13 +446,13 @@ .app-container { flex-direction: column; } - + .controls { width: 100%; max-height: 40vh; order: -1; } - + .map-container { height: 60vh; } @@ -465,3 +465,26 @@ 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; +}