# Phase 2: Routes + Enhanced Navigation **Timeline**: Week 2 **Goal**: Add routes visualization and better date navigation **Dependencies**: Phase 1 complete **Status**: Ready for implementation ## 🎯 Phase Objectives Build on Phase 1 MVP by adding: - βœ… Routes layer with speed-based coloring - βœ… Enhanced date navigation (Previous/Next Day/Week/Month) - βœ… Layer toggle controls (Points, Routes) - βœ… Improved map controls - βœ… Auto-fit bounds to visible data - βœ… E2E tests **Deploy Decision**: Users can visualize their travel routes with speed indicators. --- ## πŸ“‹ Features Checklist - [ ] Routes layer connecting points - [ ] Speed-based route coloring (green = slow, red = fast) - [ ] Date picker with Previous/Next buttons - [ ] Quick shortcuts (Day, Week, Month) - [ ] Layer toggle controls UI - [ ] Toggle between Points and Routes - [ ] Map auto-fits to visible layers - [ ] E2E tests passing --- ## πŸ—οΈ New Files (Phase 2) ``` app/javascript/maps_v2/ β”œβ”€β”€ layers/ β”‚ └── routes_layer.js # NEW: Routes with speed colors β”œβ”€β”€ controllers/ β”‚ β”œβ”€β”€ date_picker_controller.js # NEW: Date navigation β”‚ └── layer_controls_controller.js # NEW: Layer toggles └── utils/ └── date_helpers.js # NEW: Date manipulation e2e/v2/ └── phase-2-routes.spec.ts # NEW: E2E tests ``` --- ## 2.1 Routes Layer Routes connecting points with speed-based coloring. **File**: `app/javascript/maps_v2/layers/routes_layer.js` ```javascript import { BaseLayer } from './base_layer' /** * Routes layer with speed-based coloring * Connects points to show travel paths */ export class RoutesLayer extends BaseLayer { constructor(map, options = {}) { super(map, { id: 'routes', ...options }) } getSourceConfig() { return { type: 'geojson', data: this.data || { type: 'FeatureCollection', features: [] }, lineMetrics: true // Enable gradient lines } } getLayerConfigs() { return [ { id: this.id, type: 'line', source: this.sourceId, layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': [ 'interpolate', ['linear'], ['get', 'speed'], 0, '#22c55e', // 0 km/h = green 30, '#eab308', // 30 km/h = yellow 60, '#f97316', // 60 km/h = orange 100, '#ef4444' // 100+ km/h = red ], 'line-width': 3, 'line-opacity': 0.8 } } ] } } ``` --- ## 2.2 Date Helpers Utilities for date manipulation. **File**: `app/javascript/maps_v2/utils/date_helpers.js` ```javascript /** * Add days to a date * @param {Date} date * @param {number} days * @returns {Date} */ export function addDays(date, days) { const result = new Date(date) result.setDate(result.getDate() + days) return result } /** * Add months to a date * @param {Date} date * @param {number} months * @returns {Date} */ export function addMonths(date, months) { const result = new Date(date) result.setMonth(result.getMonth() + months) return result } /** * Get start of day * @param {Date} date * @returns {Date} */ export function startOfDay(date) { const result = new Date(date) result.setHours(0, 0, 0, 0) return result } /** * Get end of day * @param {Date} date * @returns {Date} */ export function endOfDay(date) { const result = new Date(date) result.setHours(23, 59, 59, 999) return result } /** * Get start of month * @param {Date} date * @returns {Date} */ export function startOfMonth(date) { return new Date(date.getFullYear(), date.getMonth(), 1) } /** * Get end of month * @param {Date} date * @returns {Date} */ export function endOfMonth(date) { return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999) } /** * Format date for API (ISO 8601) * @param {Date} date * @returns {string} */ export function formatForAPI(date) { return date.toISOString() } /** * Format date for display * @param {Date} date * @returns {string} */ export function formatForDisplay(date) { return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) } ``` --- ## 2.3 Date Picker Controller Enhanced date navigation with shortcuts. **File**: `app/javascript/maps_v2/controllers/date_picker_controller.js` ```javascript import { Controller } from '@hotwired/stimulus' import { addDays, addMonths, startOfDay, endOfDay, startOfMonth, endOfMonth, formatForAPI, formatForDisplay } from '../utils/date_helpers' /** * Date picker controller with navigation shortcuts * Provides Previous/Next Day/Week/Month buttons */ export default class extends Controller { static values = { startDate: String, endDate: String } static targets = ['startInput', 'endInput', 'display'] static outlets = ['map'] connect() { this.updateDisplay() } /** * Navigate to previous day */ previousDay(event) { event?.preventDefault() this.adjustDates(-1, 'day') } /** * Navigate to next day */ nextDay(event) { event?.preventDefault() this.adjustDates(1, 'day') } /** * Navigate to previous week */ previousWeek(event) { event?.preventDefault() this.adjustDates(-7, 'day') } /** * Navigate to next week */ nextWeek(event) { event?.preventDefault() this.adjustDates(7, 'day') } /** * Navigate to previous month */ previousMonth(event) { event?.preventDefault() this.adjustDates(-1, 'month') } /** * Navigate to next month */ nextMonth(event) { event?.preventDefault() this.adjustDates(1, 'month') } /** * Adjust dates by amount * @param {number} amount * @param {'day'|'month'} unit */ adjustDates(amount, unit) { const currentStart = new Date(this.startDateValue) let newStart, newEnd if (unit === 'day') { newStart = startOfDay(addDays(currentStart, amount)) newEnd = endOfDay(newStart) } else if (unit === 'month') { const adjusted = addMonths(currentStart, amount) newStart = startOfMonth(adjusted) newEnd = endOfMonth(adjusted) } this.startDateValue = formatForAPI(newStart) this.endDateValue = formatForAPI(newEnd) this.updateDisplay() this.notifyMapController() } /** * Handle manual date input change */ dateChanged() { const startInput = this.startInputTarget.value const endInput = this.endInputTarget.value if (startInput && endInput) { const start = startOfDay(new Date(startInput)) const end = endOfDay(new Date(endInput)) this.startDateValue = formatForAPI(start) this.endDateValue = formatForAPI(end) this.updateDisplay() this.notifyMapController() } } /** * Update display text */ updateDisplay() { if (!this.hasDisplayTarget) return const start = new Date(this.startDateValue) const end = new Date(this.endDateValue) // Check if it's a single day if (this.isSameDay(start, end)) { this.displayTarget.textContent = formatForDisplay(start) } // Check if it's a full month else if (this.isFullMonth(start, end)) { this.displayTarget.textContent = start.toLocaleDateString('en-US', { year: 'numeric', month: 'long' }) } // Range else { this.displayTarget.textContent = `${formatForDisplay(start)} - ${formatForDisplay(end)}` } } /** * Notify map controller of date change */ notifyMapController() { if (this.hasMapOutlet) { this.mapOutlet.startDateValue = this.startDateValue this.mapOutlet.endDateValue = this.endDateValue this.mapOutlet.loadMapData() } } /** * Check if two dates are the same day */ isSameDay(date1, date2) { return ( date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() ) } /** * Check if range is a full month */ isFullMonth(start, end) { const monthStart = startOfMonth(start) const monthEnd = endOfMonth(start) return ( this.isSameDay(start, monthStart) && this.isSameDay(end, monthEnd) ) } } ``` --- ## 2.4 Layer Controls Controller Toggle visibility of map layers. **File**: `app/javascript/maps_v2/controllers/layer_controls_controller.js` ```javascript import { Controller } from '@hotwired/stimulus' /** * Layer controls controller * Manages layer visibility toggles */ export default class extends Controller { static targets = ['button'] static outlets = ['map'] /** * Toggle a layer * @param {Event} event */ toggleLayer(event) { const button = event.currentTarget const layerName = button.dataset.layer if (!this.hasMapOutlet) return // Toggle layer in map controller const layer = this.mapOutlet[`${layerName}Layer`] if (layer) { layer.toggle() // Update button state button.classList.toggle('active', layer.visible) button.setAttribute('aria-pressed', layer.visible) } } } ``` --- ## 2.5 Update Map Controller Add routes support and layer controls. **File**: `app/javascript/maps_v2/controllers/map_controller.js` (update) ```javascript import { Controller } from '@hotwired/stimulus' import maplibregl from 'maplibre-gl' import { ApiClient } from '../services/api_client' import { PointsLayer } from '../layers/points_layer' import { RoutesLayer } from '../layers/routes_layer' // NEW import { pointsToGeoJSON } from '../utils/geojson_transformers' import { PopupFactory } from '../components/popup_factory' /** * Main map controller for Maps V2 * Phase 2: Add routes layer */ export default class extends Controller { static values = { apiKey: String, startDate: String, endDate: String } static targets = ['container', 'loading'] connect() { this.initializeMap() this.initializeAPI() this.loadMapData() } disconnect() { this.map?.remove() } initializeMap() { this.map = new maplibregl.Map({ container: this.containerTarget, style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', center: [0, 0], zoom: 2 }) this.map.addControl(new maplibregl.NavigationControl(), 'top-right') this.map.on('click', 'points', this.handlePointClick.bind(this)) this.map.on('mouseenter', 'points', () => { this.map.getCanvas().style.cursor = 'pointer' }) this.map.on('mouseleave', 'points', () => { this.map.getCanvas().style.cursor = '' }) } initializeAPI() { this.api = new ApiClient(this.apiKeyValue) } async loadMapData() { this.showLoading() try { const points = await this.api.fetchAllPoints({ start_at: this.startDateValue, end_at: this.endDateValue, onProgress: this.updateLoadingProgress.bind(this) }) console.log(`Loaded ${points.length} points`) // Transform to GeoJSON const pointsGeoJSON = pointsToGeoJSON(points) // Create/update points layer if (!this.pointsLayer) { this.pointsLayer = new PointsLayer(this.map) if (this.map.loaded()) { this.pointsLayer.add(pointsGeoJSON) } else { this.map.on('load', () => { this.pointsLayer.add(pointsGeoJSON) }) } } else { this.pointsLayer.update(pointsGeoJSON) } // NEW: Create routes from points const routesGeoJSON = this.pointsToRoutes(points) if (!this.routesLayer) { this.routesLayer = new RoutesLayer(this.map) if (this.map.loaded()) { this.routesLayer.add(routesGeoJSON) } else { this.map.on('load', () => { this.routesLayer.add(routesGeoJSON) }) } } else { this.routesLayer.update(routesGeoJSON) } // Fit map to data if (points.length > 0) { this.fitMapToBounds(pointsGeoJSON) } } catch (error) { console.error('Failed to load map data:', error) alert('Failed to load location data. Please try again.') } finally { this.hideLoading() } } /** * Convert points to routes (LineStrings) * NEW in Phase 2 */ pointsToRoutes(points) { if (points.length < 2) { return { type: 'FeatureCollection', features: [] } } // Sort by timestamp const sorted = points.sort((a, b) => a.timestamp - b.timestamp) // Group into continuous segments (max 5 hours gap) const segments = [] let currentSegment = [sorted[0]] for (let i = 1; i < sorted.length; i++) { const prev = sorted[i - 1] const curr = sorted[i] const timeDiff = curr.timestamp - prev.timestamp // If more than 5 hours gap, start new segment if (timeDiff > 5 * 3600) { if (currentSegment.length > 1) { segments.push(currentSegment) } currentSegment = [curr] } else { currentSegment.push(curr) } } if (currentSegment.length > 1) { segments.push(currentSegment) } // Convert segments to LineStrings const features = segments.map(segment => { const coordinates = segment.map(p => [p.longitude, p.latitude]) // Calculate average speed const speeds = segment .map(p => p.velocity || 0) .filter(v => v > 0) const avgSpeed = speeds.length > 0 ? speeds.reduce((a, b) => a + b) / speeds.length : 0 return { type: 'Feature', geometry: { type: 'LineString', coordinates }, properties: { speed: avgSpeed * 3.6, // m/s to km/h pointCount: segment.length } } }) return { type: 'FeatureCollection', features } } handlePointClick(e) { const feature = e.features[0] const coordinates = feature.geometry.coordinates.slice() const properties = feature.properties new maplibregl.Popup() .setLngLat(coordinates) .setHTML(PopupFactory.createPointPopup(properties)) .addTo(this.map) } fitMapToBounds(geojson) { const coordinates = geojson.features.map(f => f.geometry.coordinates) const bounds = coordinates.reduce((bounds, coord) => { return bounds.extend(coord) }, new maplibregl.LngLatBounds(coordinates[0], coordinates[0])) this.map.fitBounds(bounds, { padding: 50, maxZoom: 15 }) } showLoading() { this.loadingTarget.classList.remove('hidden') } hideLoading() { this.loadingTarget.classList.add('hidden') } updateLoadingProgress({ loaded, totalPages, progress }) { const percentage = Math.round(progress * 100) this.loadingTarget.textContent = `Loading... ${percentage}%` } } ``` --- ## 2.6 Updated View Template **File**: `app/views/maps_v2/index.html.erb` (update) ```erb
to
``` --- ## πŸ§ͺ E2E Tests **File**: `e2e/v2/phase-2-routes.spec.ts` ```typescript import { test, expect } from '@playwright/test' import { login, waitForMap } from './helpers/setup' test.describe('Phase 2: Routes + Enhanced Navigation', () => { test.beforeEach(async ({ page }) => { await login(page) await page.goto('/maps_v2') await waitForMap(page) }) test('routes layer renders', async ({ page }) => { const hasRoutes = await page.evaluate(() => { const map = window.mapInstance const source = map?.getSource('routes-source') return source && source._data?.features?.length > 0 }) expect(hasRoutes).toBe(true) }) test('routes have speed-based colors', async ({ page }) => { const routeLayer = await page.evaluate(() => { const map = window.mapInstance return map?.getLayer('routes') }) expect(routeLayer).toBeTruthy() }) test('layer controls toggle points', async ({ page }) => { const pointsButton = page.locator('button[data-layer="points"]') await expect(pointsButton).toHaveClass(/active/) // Toggle off await pointsButton.click() await expect(pointsButton).not.toHaveClass(/active/) // Verify layer hidden const isHidden = await page.evaluate(() => { const map = window.mapInstance return map?.getLayoutProperty('points', 'visibility') === 'none' }) expect(isHidden).toBe(true) // Toggle back on await pointsButton.click() await expect(pointsButton).toHaveClass(/active/) }) test('layer controls toggle routes', async ({ page }) => { const routesButton = page.locator('button[data-layer="routes"]') await routesButton.click() const isHidden = await page.evaluate(() => { const map = window.mapInstance return map?.getLayoutProperty('routes', 'visibility') === 'none' }) expect(isHidden).toBe(true) }) test('previous day button works', async ({ page }) => { const dateDisplay = page.locator('[data-date-picker-target="display"]') const initialText = await dateDisplay.textContent() await page.click('button[title="Previous Day"]') await waitForMap(page) const newText = await dateDisplay.textContent() expect(newText).not.toBe(initialText) }) test('next day button works', async ({ page }) => { const dateDisplay = page.locator('[data-date-picker-target="display"]') const initialText = await dateDisplay.textContent() await page.click('button[title="Next Day"]') await waitForMap(page) const newText = await dateDisplay.textContent() expect(newText).not.toBe(initialText) }) test('previous week button works', async ({ page }) => { await page.click('button[title="Previous Week"]') await waitForMap(page) // Should have loaded different data expect(page.locator('[data-map-target="loading"]')).toHaveClass(/hidden/) }) test('previous month button works', async ({ page }) => { await page.click('button[title="Previous Month"]') await waitForMap(page) expect(page.locator('[data-map-target="loading"]')).toHaveClass(/hidden/) }) test('manual date input works', async ({ page }) => { const startInput = page.locator('input[data-date-picker-target="startInput"]') const endInput = page.locator('input[data-date-picker-target="endInput"]') await startInput.fill('2024-06-01') await endInput.fill('2024-06-30') await waitForMap(page) const dateDisplay = page.locator('[data-date-picker-target="display"]') const text = await dateDisplay.textContent() expect(text).toContain('June 2024') }) test('date display updates correctly', async ({ page }) => { const dateDisplay = page.locator('[data-date-picker-target="display"]') await expect(dateDisplay).not.toBeEmpty() }) test('both layers can be visible simultaneously', async ({ page }) => { const pointsVisible = await page.evaluate(() => { const map = window.mapInstance return map?.getLayoutProperty('points', 'visibility') === 'visible' }) const routesVisible = await page.evaluate(() => { const map = window.mapInstance return map?.getLayoutProperty('routes', 'visibility') === 'visible' }) expect(pointsVisible).toBe(true) expect(routesVisible).toBe(true) }) }) ``` --- ## βœ… Phase 2 Completion Checklist ### Implementation - [ ] Created routes_layer.js - [ ] Created date_picker_controller.js - [ ] Created layer_controls_controller.js - [ ] Created date_helpers.js - [ ] Updated map_controller.js - [ ] Updated view template - [ ] Routes render with speed colors - [ ] Layer toggles work - [ ] Date navigation works ### Testing - [ ] All E2E tests pass - [ ] Phase 1 tests still pass (regression) - [ ] Manual testing complete - [ ] Tested all date navigation buttons - [ ] Tested layer toggles ### Performance - [ ] Routes render smoothly - [ ] Date changes load quickly - [ ] No performance regression from Phase 1 --- ## πŸš€ Deployment ```bash git checkout -b maps-v2-phase-2 git add app/javascript/maps_v2/ app/views/maps_v2/ e2e/v2/ git commit -m "feat: Maps V2 Phase 2 - Routes and navigation" # Run tests npx playwright test e2e/v2/phase-1-mvp.spec.ts npx playwright test e2e/v2/phase-2-routes.spec.ts # Deploy to staging git push origin maps-v2-phase-2 ``` --- ## πŸŽ‰ What's Next? **Phase 3**: Add heatmap layer and mobile-optimized UI with bottom sheet.