From bd83cca99dea329cd2c18f076c19968583bd0606 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Tue, 18 Aug 2026 15:12:50 +0300 Subject: [PATCH] feat: create static directory and frontend files --- .../2026-08-18-implement-leaflet-frontend.md | 82 +++++ static/app.js | 294 ++++++++++++++++++ static/index.html | 58 ++++ static/styles.css | 292 +++++++++++++++++ 4 files changed, 726 insertions(+) create mode 100644 docs/plans/2026-08-18-implement-leaflet-frontend.md create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/styles.css 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 = `

${feature.properties?.title || 'Transfer'}

`; + + if (feature.properties?.connection_time_formatted) { + 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 = '

No routes found

'; + 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 += ` +
+
+ ${duration} + ${cost > 0 ? cost + ' units' : 'N/A'} +
+
+ ${transfers} transfer${transfers !== 1 ? 's' : ''} +
+
+ `; + }); + + routesListEl.innerHTML = html; + + // Add click handlers to route cards + document.querySelectorAll('.route-card').forEach(card => { + card.addEventListener('click', function() { + // Remove selected class from all cards + document.querySelectorAll('.route-card').forEach(c => c.classList.remove('selected')); + this.classList.add('selected'); + + // Fetch and render GeoJSON for this route + const searchId = this.dataset.searchId; + const routeId = this.dataset.routeId; + fetchGeoJSON(searchId, routeId); + }); + }); +} + +// Fetch GeoJSON for a route +async function fetchGeoJSON(searchId, routeId) { + try { + const response = await fetch(`/v1/routes/${searchId}/${routeId}/geojson`); + if (!response.ok) { + throw new Error(`Failed to fetch GeoJSON: ${response.statusText}`); + } + + const geojsonData = await response.json(); + renderGeoJSON(geojsonData); + } catch (error) { + console.error('Error fetching GeoJSON:', error); + showError('Failed to load route map'); + } +} + +// Search for routes +async function searchRoutes(formData) { + showLoading(); + hideError(); + + try { + const response = await fetch('/v1/routes/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: formData.get('from'), + to: formData.get('to'), + date: formData.get('date') + }) + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.statusText}`); + } + + const searchData = await response.json(); + + // Render routes list + if (searchData.routes) { + renderRoutesList(searchData.routes); + } else if (searchData.routes === null || searchData.routes === undefined) { + document.getElementById('routes-list').innerHTML = '

No routes found

'; + } + + // If there's only one route, select it automatically + const routes = searchData.routes || []; + if (routes.length === 1) { + const firstCard = document.querySelector('.route-card'); + if (firstCard) { + firstCard.classList.add('selected'); + fetchGeoJSON(routes[0].search_id, routes[0].id); + } + } + + } catch (error) { + console.error('Error searching routes:', error); + showError('Failed to search routes. Please try again.'); + document.getElementById('routes-list').innerHTML = '

Search failed

'; + } finally { + hideLoading(); + } +} + +// Initialize search form +document.getElementById('search-form').addEventListener('submit', function(e) { + e.preventDefault(); + + const formData = new FormData(this); + searchRoutes(formData); +}); + +// Set default date to today +const dateInput = document.getElementById('travel-date'); +const today = new Date().toISOString().split('T')[0]; +dateInput.value = today; +dateInput.min = today; diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..11aed9b --- /dev/null +++ b/static/index.html @@ -0,0 +1,58 @@ + + + + + + Trip Planner + + + + +
+
+

Trip Planner

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+
+

Found Routes

+
+

Search for routes to see results here

+
+
+ +
+
+
+
+ + + + +
+ + + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..5eacecb --- /dev/null +++ b/static/styles.css @@ -0,0 +1,292 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #f5f5f5; + color: #333; + height: 100vh; + overflow: hidden; +} + +.app-container { + display: flex; + flex-direction: column; + height: 100vh; +} + +.header { + background-color: #2c3e50; + color: white; + padding: 1rem 2rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.header h1 { + font-size: 1.5rem; + font-weight: 600; +} + +.search-container { + background-color: #fff; + padding: 1rem 2rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.search-form { + display: flex; + gap: 1rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 500; + color: #555; +} + +.form-group input { + padding: 0.5rem 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + min-width: 150px; +} + +.form-group input:focus { + outline: none; + border-color: #1976d2; + box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1); +} + +.search-btn { + padding: 0.5rem 1.5rem; + background-color: #1976d2; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.search-btn:hover { + background-color: #1565c0; +} + +.search-btn:disabled { + background-color: #90caf9; + cursor: not-allowed; +} + +.content-container { + display: flex; + flex: 1; + overflow: hidden; +} + +.routes-panel { + width: 350px; + background-color: #fff; + border-right: 1px solid #ddd; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.routes-panel h2 { + padding: 1rem; + font-size: 1.125rem; + font-weight: 600; + border-bottom: 1px solid #eee; + background-color: #f9f9f9; +} + +.routes-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.empty-message { + padding: 2rem; + text-align: center; + color: #888; +} + +.route-card { + background-color: #fff; + border: 1px solid #ddd; + border-radius: 6px; + padding: 1rem; + margin-bottom: 0.5rem; + cursor: pointer; + transition: box-shadow 0.2s, border-color 0.2s; +} + +.route-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-color: #1976d2; +} + +.route-card.selected { + border-color: #1976d2; + background-color: #e3f2fd; +} + +.route-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.route-duration { + font-weight: 600; + color: #2c3e50; +} + +.route-transfers { + font-size: 0.875rem; + color: #666; +} + +.route-cost { + font-weight: 600; + color: #27ae60; +} + +.map-panel { + flex: 1; + position: relative; +} + +#map { + width: 100%; + height: 100%; +} + +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.loading-overlay.hidden { + display: none; +} + +.loading-spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #1976d2; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading-overlay p { + color: white; + font-size: 1rem; +} + +.error-message { + position: fixed; + top: 1rem; + right: 1rem; + background-color: #e74c3c; + color: white; + padding: 1rem; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 1001; + max-width: 400px; +} + +.error-message.hidden { + display: none; +} + +/* Leaflet popup styling */ +.leaflet-popup-content { + min-width: 200px; +} + +.leaflet-popup-content h4 { + margin-bottom: 0.5rem; + color: #2c3e50; +} + +.leaflet-popup-content p { + margin: 0.25rem 0; + font-size: 0.875rem; +} + +/* Transport type colors */ +.transport-plane { + color: #ff9800; +} + +.transport-train { + color: #1976d2; +} + +.transport-bus { + color: #cddc39; +} + +/* Responsive layout */ +@media (max-width: 768px) { + .content-container { + flex-direction: column; + } + + .routes-panel { + width: 100%; + height: 40%; + border-right: none; + border-bottom: 1px solid #ddd; + } + + .map-panel { + height: 60%; + } + + .search-form { + flex-direction: column; + align-items: stretch; + } + + .form-group { + width: 100%; + } + + .form-group input { + width: 100%; + } +}