# 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