diff --git a/docs/plans/2026-08-18-implement-leaflet-frontend.md b/docs/plans/2026-08-18-implement-leaflet-frontend.md new file mode 100644 index 0000000..c85f54a --- /dev/null +++ b/docs/plans/2026-08-18-implement-leaflet-frontend.md @@ -0,0 +1,82 @@ +# Implement Leaflet + OpenStreetMap Frontend + +## Overview +- Add a complete frontend web interface using Leaflet + OpenStreetMap tiles for route visualization +- Provide a search form for route parameters (from city, to city, date) +- Display found routes in a list with duration, transfers, and cost +- Render route geometry as GeoJSON on an interactive map +- Real segments displayed as solid lines with color by transport type +- Synthetic segments displayed as dashed lines +- Transfer points displayed as markers with popup information + +## Context +- Files/components involved: + - `static/index.html` - main HTML page with Leaflet + OSM integration + - `static/styles.css` - CSS styling for the frontend + - `static/app.js` - JavaScript for search form, API calls, and map rendering + - `cmd/api/main.go` - update to serve static files and the frontend +- Related patterns found: + - GeoJSON FeatureCollection response from `/v1/routes/{search_id}/{route_id}/geojson` + - LineString features with properties: `transport`, `transport_type`, `kind`, `synthetic`, `duration`, `cost`, `is_transfer`, `stroke_color`, `stroke_width`, `stroke_dasharray` + - Point features for transfer markers with properties: `marker_type`, `title`, `connection_time`, `connection_time_formatted`, `transfer_type`, `is_transfer`, `stroke_color`, `stroke_width` +- Dependencies identified: + - Leaflet 1.9.4 (CSS and JS from CDN) + - OpenStreetMap tiles + +## Development Approach +- **Testing approach**: Regular (code first, then verify) +- Complete each task fully before moving to the next +- Make small, focused changes +- **CRITICAL: ensure all frontend files are properly linked and functional** +- Maintain backward compatibility with existing API endpoints + +## Testing Strategy +- **Manual testing**: Test search form, route list, map rendering, and popup interactions +- **UI/UX testing**: Verify responsive layout, loading states, error handling + +## Progress Tracking +- Mark completed items with `[x]` immediately when done +- Add newly discovered tasks with ➕ prefix +- Document issues/blockers with ⚠️ prefix +- Update plan if implementation deviates from original scope +- Keep plan in sync with actual work done + +## What Goes Where +- **Implementation Steps** (`[ ]` checkboxes): tasks achievable within this codebase - frontend files, Go server updates +- **Post-Completion** (no checkboxes): items requiring external action - manual testing in browser + +## Implementation Steps + +### Task 1: Create static directory and HTML structure +- [x] create `static/` directory +- [x] create `static/index.html` with Leaflet + OSM integration and search form +- [x] create `static/styles.css` with styling for layout, routes list, and map +- [x] create `static/app.js` with API calls and map rendering logic + +### Task 2: Update Go server to serve static files +- [ ] update `cmd/api/main.go` to serve static files from `static/` directory +- [ ] add route for `/static/*` to serve CSS, JS, and other assets +- [ ] add route for `/` or `/index.html` to serve the frontend + +### Task 3: Verify frontend functionality +- [ ] verify search form works and calls `/v1/routes/search` endpoint +- [ ] verify routes list displays correctly with duration, transfers, cost +- [ ] verify map renders with Leaflet + OpenStreetMap tiles +- [ ] verify GeoJSON is fetched and rendered on the map +- [ ] verify transfer markers have popups with connection info + +## Technical Details +- Leaflet CSS: `https://unpkg.com/leaflet@1.9.4/dist/leaflet.css` +- Leaflet JS: `https://unpkg.com/leaflet@1.9.4/dist/leaflet.js` +- OpenStreetMap tiles: `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png` +- GeoJSON rendering: use `L.geoJSON()` with custom style functions for real vs synthetic edges +- Transport colors: plane = `#ff9800` (orange), train = `#1976d2` (blue), bus = `#cddc39` (lime) + +## Post-Completion +*Items requiring manual intervention or external systems - no checkboxes, informational only* + +**Manual verification**: +- Test search form in browser +- Verify map renders correctly with routes +- Test popup interactions for transfer points +- Verify responsive layout on different screen sizes diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..045e4d1 --- /dev/null +++ b/static/app.js @@ -0,0 +1,294 @@ +// Initialize map +const map = L.map('map').setView([55.7558, 37.6173], 5); + +// Add OpenStreetMap tiles +L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 18 +}).addTo(map); + +// Layers to store map features +let routeLayers = L.layerGroup().addTo(map); +let transferMarkers = L.layerGroup().addTo(map); + +// Transport colors +const transportColors = { + 'plane': '#ff9800', + 'train': '#1976d2', + 'bus': '#cddc39', + 'other': '#9e9e9e' +}; + +// Get transport color +function getTransportColor(feature) { + const transportType = feature.properties.transport_type || feature.properties.transport || 'other'; + return transportColors[transportType] || transportColors.other; +} + +// Get line style based on feature properties +function getLineStyle(feature) { + if (feature.properties && feature.properties.synthetic === 'true') { + return { + color: getTransportColor(feature), + weight: 2, + dashArray: '5, 5', + opacity: 0.7 + }; + } + + return { + color: getTransportColor(feature), + weight: 3, + opacity: 0.8 + }; +} + +// Get marker style based on feature properties +function getMarkerStyle(feature) { + const color = feature.properties.stroke_color || getTransportColor(feature); + const width = feature.properties.stroke_width || 3; + + return { + color: color, + weight: width, + fillColor: '#fff', + fillOpacity: 1 + }; +} + +// Format duration from seconds to readable format +function formatDuration(seconds) { + if (!seconds || seconds === 0) return '0h'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +// Format connection time +function formatConnectionTime(connectionTime) { + if (!connectionTime) return 'N/A'; + return formatDuration(connectionTime); +} + +// Render GeoJSON on map +function renderGeoJSON(geojsonData) { + // Clear existing layers + routeLayers.clearLayers(); + transferMarkers.clearLayers(); + + if (!geojsonData || !geojsonData.features) { + return; + } + + // Process features + geojsonData.features.forEach(feature => { + const kind = feature.properties?.kind || feature.type; + + if (kind === 'LineString' || feature.geometry?.type === 'LineString') { + // LineString feature - route segment + const style = getLineStyle(feature); + + L.geoJSON(feature, { + style: function(feature) { + return getLineStyle(feature); + }, + onEachFeature: function(feature, layer) { + layer.addTo(routeLayers); + } + }).addTo(routeLayers); + } else if (kind === 'Point' || feature.geometry?.type === 'Point') { + // Point feature - transfer marker + const markerType = feature.properties?.marker_type; + + if (markerType === 'transfer' || feature.properties?.is_transfer === 'true') { + const style = getMarkerStyle(feature); + + const marker = L.circleMarker([feature.geometry.coordinates[1], feature.geometry.coordinates[0]], { + color: style.color, + weight: style.weight, + fillColor: style.fillColor, + fillOpacity: style.fillOpacity, + radius: 6 + }); + + // Create popup content + let popupContent = `
Connection time: ${feature.properties.connection_time_formatted}
`; + } else if (feature.properties?.connection_time) { + popupContent += `Connection time: ${formatConnectionTime(feature.properties.connection_time)}
`; + } + + if (feature.properties?.transfer_type) { + popupContent += `Transfer type: ${feature.properties.transfer_type}
`; + } + + marker.bindPopup(popupContent); + marker.addTo(transferMarkers); + } + } + }); + + // Fit map to bounds if there are features + if (routeLayers.getLayers().length > 0 || transferMarkers.getLayers().length > 0) { + const group = new L.featureGroup([...routeLayers.getLayers(), ...transferMarkers.getLayers()]); + map.fitBounds(group.getBounds().pad(0.1)); + } +} + +// Show loading overlay +function showLoading() { + document.getElementById('loading-overlay').classList.remove('hidden'); +} + +// Hide loading overlay +function hideLoading() { + document.getElementById('loading-overlay').classList.add('hidden'); +} + +// Show error message +function showError(message) { + const errorEl = document.getElementById('error-message'); + errorEl.textContent = message; + errorEl.classList.remove('hidden'); + + setTimeout(() => { + errorEl.classList.add('hidden'); + }, 5000); +} + +// Hide error message +function hideError() { + document.getElementById('error-message').classList.add('hidden'); +} + +// Render routes list +function renderRoutesList(routes) { + const routesListEl = document.getElementById('routes-list'); + + if (!routes || routes.length === 0) { + routesListEl.innerHTML = ''; + return; + } + + let html = ''; + routes.forEach((route, index) => { + const duration = formatDuration(route.duration_seconds || route.duration || 0); + const transfers = route.transfers || route.transfer_count || 0; + const cost = route.cost || 0; + + html += ` +