# Phase 2: Routes + Layer Controls **Timeline**: Week 2 **Goal**: Add routes visualization with V1-compatible splitting and layer controls **Dependencies**: Phase 1 complete (βœ… Implemented in commit 0ca4cb20) **Status**: βœ… **IMPLEMENTED** - 14/17 tests passing (82%) ## 🎯 Phase Objectives Build on Phase 1 MVP by adding: - βœ… Routes layer with solid coloring - βœ… V1-compatible route splitting (distance + time thresholds) - βœ… Layer toggle controls (Points, Routes, Clustering) - βœ… Point clustering toggle - βœ… Auto-fit bounds to visible data - βœ… E2E tests **Deploy Decision**: Users can visualize their travel routes with speed indicators and control layer visibility. --- ## πŸ“‹ Features Checklist - βœ… Routes layer connecting points - βœ… Orange route coloring (green = slow, red = fast) - βœ… V1-compatible route splitting (500m distance, 60min time) - βœ… Layer toggle controls UI - βœ… Toggle visibility for Points and Routes layers - βœ… Toggle clustering for Points layer - βœ… Map auto-fits to visible layers - βœ… E2E tests (14/17 passing) --- ## πŸ—οΈ Implemented Files (Phase 2) ``` app/javascript/maps_v2/ β”œβ”€β”€ layers/ β”‚ β”œβ”€β”€ routes_layer.js # βœ… Routes with speed colors + V1 splitting β”‚ └── points_layer.js # βœ… Updated: toggleable clustering β”œβ”€β”€ controllers/ β”‚ └── maps_v2_controller.js # βœ… Updated: layer & clustering toggles └── views/ └── maps_v2/index.html.erb # βœ… Updated: layer control buttons e2e/v2/ β”œβ”€β”€ phase-2-routes.spec.js # βœ… 17 E2E tests └── helpers/setup.js # βœ… Updated: layer visibility helpers ``` **Key Features:** - Routes layer with V1-compatible splitting logic - Point clustering toggle (on/off) - Layer visibility toggles (Points, Routes) - Orange route coloring - Distance threshold: 500m (configurable) - Time threshold: 60 minutes (configurable) --- ## 2.1 Routes Layer Routes connecting points with solid coloring. **File**: `app/javascript/maps_v2/layers/routes_layer.js` ```javascript import { BaseLayer } from './base_layer' /** * Routes layer with solid 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 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.3 Point Clustering Toggle Enable users to toggle between clustered and non-clustered point display. **File**: `app/javascript/maps_v2/layers/points_layer.js` (update) Add clustering toggle capability to PointsLayer: ```javascript export class PointsLayer extends BaseLayer { constructor(map, options = {}) { super(map, { id: 'points', ...options }) this.clusterRadius = options.clusterRadius || 50 this.clusterMaxZoom = options.clusterMaxZoom || 14 this.clusteringEnabled = options.clustering !== false // Default: enabled } getSourceConfig() { return { type: 'geojson', data: this.data || { type: 'FeatureCollection', features: [] }, cluster: this.clusteringEnabled, // Dynamic clustering clusterMaxZoom: this.clusterMaxZoom, clusterRadius: this.clusterRadius } } /** * Toggle clustering on/off * Recreates the source with new clustering setting */ toggleClustering(enabled) { if (!this.data) { console.warn('Cannot toggle clustering: no data loaded') return } this.clusteringEnabled = enabled const currentData = this.data const wasVisible = this.visible // Remove layers and source this.getLayerIds().forEach(layerId => { if (this.map.getLayer(layerId)) { this.map.removeLayer(layerId) } }) if (this.map.getSource(this.sourceId)) { this.map.removeSource(this.sourceId) } // Re-add with new clustering setting this.map.addSource(this.sourceId, this.getSourceConfig()) this.getLayerConfigs().forEach(layerConfig => { this.map.addLayer(layerConfig) }) // Restore state this.visible = wasVisible this.setVisibility(wasVisible) this.data = currentData this.map.getSource(this.sourceId).setData(currentData) console.log(`Points clustering ${enabled ? 'enabled' : 'disabled'}`) } } ``` **Benefits:** - **Clustered mode**: Better performance with many points - **Non-clustered mode**: See all individual points - **User control**: Toggle based on current needs --- ## 2.4 Update Map Controller Add routes support, layer controls, and clustering toggle. **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.js` ```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.js npx playwright test e2e/v2/phase-2-routes.spec.js # 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.