26 KiB
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
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
/**
* 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
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
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)
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)
<div class="maps-v2-container">
<!-- Map -->
<div class="map-wrapper"
data-controller="map date-picker layer-controls"
data-map-api-key-value="<%= current_api_user.api_key %>"
data-map-start-date-value="<%= @start_date.iso8601 %>"
data-map-end-date-value="<%= @end_date.iso8601 %>"
data-date-picker-start-date-value="<%= @start_date.iso8601 %>"
data-date-picker-end-date-value="<%= @end_date.iso8601 %>"
data-date-picker-map-outlet=".map-wrapper"
data-layer-controls-map-outlet=".map-wrapper">
<div data-map-target="container" class="map-container"></div>
<div data-map-target="loading" class="loading-overlay hidden">
<div class="loading-spinner"></div>
<div class="loading-text">Loading points...</div>
</div>
<!-- Layer Controls (top-left) -->
<div class="layer-controls">
<button data-layer-controls-target="button"
data-layer="points"
data-action="click->layer-controls#toggleLayer"
class="layer-button active"
aria-pressed="true">
Points
</button>
<button data-layer-controls-target="button"
data-layer="routes"
data-action="click->layer-controls#toggleLayer"
class="layer-button active"
aria-pressed="true">
Routes
</button>
</div>
</div>
<!-- Date Navigation Panel -->
<div class="controls-panel">
<!-- Date Display -->
<div class="date-display">
<span data-date-picker-target="display"></span>
</div>
<!-- Quick Navigation -->
<div class="date-nav">
<div class="nav-group">
<button data-action="click->date-picker#previousMonth"
class="nav-button"
title="Previous Month">
◀◀
</button>
<button data-action="click->date-picker#previousWeek"
class="nav-button"
title="Previous Week">
◀
</button>
<button data-action="click->date-picker#previousDay"
class="nav-button"
title="Previous Day">
◁
</button>
</div>
<div class="nav-group">
<button data-action="click->date-picker#nextDay"
class="nav-button"
title="Next Day">
▷
</button>
<button data-action="click->date-picker#nextWeek"
class="nav-button"
title="Next Week">
▶
</button>
<button data-action="click->date-picker#nextMonth"
class="nav-button"
title="Next Month">
▶▶
</button>
</div>
</div>
<!-- Manual Date Selection -->
<div class="date-inputs">
<input type="date"
data-date-picker-target="startInput"
data-action="change->date-picker#dateChanged"
value="<%= @start_date.strftime('%Y-%m-%d') %>"
class="date-input">
<span class="date-separator">to</span>
<input type="date"
data-date-picker-target="endInput"
data-action="change->date-picker#dateChanged"
value="<%= @end_date.strftime('%Y-%m-%d') %>"
class="date-input">
</div>
</div>
</div>
<style>
.maps-v2-container {
height: 100vh;
display: flex;
flex-direction: column;
}
.map-wrapper {
flex: 1;
position: relative;
}
.map-container {
width: 100%;
height: 100%;
}
.loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-overlay.hidden {
display: none;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 16px;
font-size: 14px;
color: #6b7280;
}
/* Layer Controls */
.layer-controls {
position: absolute;
top: 16px;
left: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 10;
}
.layer-button {
padding: 8px 16px;
background: white;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.layer-button:hover {
border-color: #3b82f6;
}
.layer-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
/* Controls Panel */
.controls-panel {
padding: 16px;
background: white;
border-top: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 24px;
}
.date-display {
font-weight: 600;
color: #111827;
min-width: 200px;
}
.date-nav {
display: flex;
gap: 16px;
}
.nav-group {
display: flex;
gap: 4px;
}
.nav-button {
padding: 8px 12px;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.nav-button:hover {
background: #f3f4f6;
border-color: #3b82f6;
}
.date-inputs {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.date-input {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
}
.date-separator {
color: #6b7280;
}
/* Mobile */
@media (max-width: 768px) {
.controls-panel {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.date-display {
text-align: center;
}
.date-nav {
justify-content: center;
}
.date-inputs {
margin-left: 0;
}
}
</style>
🧪 E2E Tests
File: e2e/v2/phase-2-routes.spec.ts
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
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.