Add interactive timeline slider with graph visualization to map

This commit implements a comprehensive timeline feature for the map interface,
allowing users to visualize and navigate through location history interactively.

**New Features:**
- Interactive timeline slider at the bottom of the map
- Real-time graph visualization showing:
  - Speed over time (km/h)
  - Battery level (%)
  - Elevation/altitude (meters)
- Play/pause animation controls for automatic timeline progression
- Smooth synchronization between timeline and map layers
- Graph type selector to switch between different metrics

**Technical Implementation:**
- New Stimulus controller (maps--timeline) for timeline UI and interactions
- Canvas-based graph rendering for performance
- Event-driven architecture for map-timeline communication
- Real-time filtering of points and routes based on timeline position
- Integration with existing MapLibre GL layers

**User Benefits:**
- Clear visualization of movement progression over time
- Easy identification of journey start, end, and direction
- Ability to "replay" trips with animation
- Additional context through speed, battery, and elevation data
- Toggleable visibility to preserve screen space when not needed

**Files Added:**
- app/javascript/controllers/maps/timeline_controller.js
- app/views/map/maplibre/_timeline.html.erb

**Files Modified:**
- app/javascript/controllers/maps/maplibre_controller.js
- app/javascript/controllers/maps/maplibre/map_data_manager.js
- app/views/map/maplibre/index.html.erb
This commit is contained in:
Claude 2025-12-23 20:23:07 +00:00
parent c8242ce902
commit f1474d105b
No known key found for this signature in database
5 changed files with 675 additions and 2 deletions

View file

@ -43,6 +43,9 @@ export class MapDataManager {
showLoading ? onProgress : null
)
// Store points in dataLoader for timeline access
this.dataLoader.allPoints = data.points
// Store visits for filtering
this.filterManager.setAllVisits(data.visits)
@ -54,6 +57,11 @@ export class MapDataManager {
this._fitMapToBounds(data.pointsGeoJSON)
}
// Update timeline with new data
if (this.controller.updateTimelineData) {
this.controller.updateTimelineData()
}
// Show success message
if (showToast) {
const pointText = data.points.length === 1 ? 'point' : 'points'

View file

@ -17,6 +17,8 @@ import { AreaSelectionManager } from './maplibre/area_selection_manager'
import { VisitsManager } from './maplibre/visits_manager'
import { PlacesManager } from './maplibre/places_manager'
import { RoutesManager } from './maplibre/routes_manager'
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
import { RoutesLayer } from 'maps_maplibre/layers/routes_layer'
/**
* Main map controller for Maps V2
@ -73,7 +75,10 @@ export default class extends Controller {
'infoDisplay',
'infoTitle',
'infoContent',
'infoActions'
'infoActions',
// Timeline
'timeline',
'timelineToggleButton'
]
async connect() {
@ -123,6 +128,10 @@ export default class extends Controller {
this.boundHandleAreaCreated = this.handleAreaCreated.bind(this)
this.cleanup.addEventListener(document, 'area:created', this.boundHandleAreaCreated)
// Listen for timeline events
this.boundHandleTimelineChange = this.handleTimelineChange.bind(this)
this.cleanup.addEventListener(document, 'timeline:timeChanged', this.boundHandleTimelineChange)
// Format initial dates
this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue))
this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue))
@ -228,6 +237,88 @@ export default class extends Controller {
}
}
/**
* Toggle timeline panel
*/
toggleTimeline() {
if (this.hasTimelineTarget) {
this.timelineTarget.classList.toggle('hidden')
// If showing timeline, update it with current data
if (!this.timelineTarget.classList.contains('hidden')) {
this.updateTimelineData()
}
}
}
/**
* Update timeline with current map data
*/
updateTimelineData() {
if (!this.hasTimelineTarget || !this.dataLoader) return
const points = this.dataLoader.allPoints || []
const startTimestamp = this.parseTimestamp(this.startDateValue)
const endTimestamp = this.parseTimestamp(this.endDateValue)
// Dispatch event to timeline controller
const event = new CustomEvent('timeline:updateData', {
detail: {
points: points,
startTimestamp: startTimestamp,
endTimestamp: endTimestamp
}
})
document.dispatchEvent(event)
}
/**
* Parse date string to Unix timestamp
*/
parseTimestamp(dateString) {
return Math.floor(new Date(dateString).getTime() / 1000)
}
/**
* Handle timeline time change event
* Filters points and routes based on selected time
*/
handleTimelineChange(event) {
const { currentTimestamp, startTimestamp, endTimestamp } = event.detail
if (!this.dataLoader?.allPoints || this.dataLoader.allPoints.length === 0) {
return
}
// Filter points up to current timestamp
const filteredPoints = this.dataLoader.allPoints.filter(point => {
return point.timestamp <= currentTimestamp
})
// Convert filtered points to GeoJSON
const filteredPointsGeoJSON = pointsToGeoJSON(filteredPoints)
// Generate routes from filtered points
const filteredRoutesGeoJSON = RoutesLayer.pointsToRoutes(filteredPoints, {
distanceThresholdMeters: this.settings.metersBetweenRoutes || 1000,
timeThresholdMinutes: this.settings.minutesBetweenRoutes || 60
})
// Update layers
if (this.layerManager) {
const pointsLayer = this.layerManager.layers.get('points')
const routesLayer = this.layerManager.layers.get('routes')
if (pointsLayer) {
pointsLayer.update(filteredPointsGeoJSON)
}
if (routesLayer) {
routesLayer.update(filteredRoutesGeoJSON)
}
}
}
// ===== Delegated Methods to Managers =====
// Settings Controller methods

View file

@ -0,0 +1,436 @@
import { Controller } from '@hotwired/stimulus'
/**
* Timeline controller for map visualization
* Displays a temporal graph and slider to navigate through location history
*/
export default class extends Controller {
static targets = [
'canvas',
'slider',
'playButton',
'currentTime',
'startLabel',
'endLabel',
'graphTypeSelect'
]
static values = {
startTimestamp: Number,
endTimestamp: Number,
currentStart: Number,
currentEnd: Number
}
connect() {
console.log('Timeline controller connected')
this.points = []
this.isPlaying = false
this.playbackSpeed = 1000 // ms per step
this.playbackInterval = null
this.graphType = 'speed' // speed, battery, elevation
this.initializeCanvas()
this.bindEvents()
}
disconnect() {
if (this.playbackInterval) {
clearInterval(this.playbackInterval)
}
this.unbindEvents()
}
initializeCanvas() {
if (!this.hasCanvasTarget) return
const canvas = this.canvasTarget
const container = canvas.parentElement
// Set canvas size to match container
const resizeCanvas = () => {
const rect = container.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
this.draw()
}
this.resizeCanvas = resizeCanvas
resizeCanvas()
}
bindEvents() {
window.addEventListener('resize', this.resizeCanvas)
// Listen for points data from map controller
document.addEventListener('timeline:updateData', this.handleDataUpdate.bind(this))
}
unbindEvents() {
window.removeEventListener('resize', this.resizeCanvas)
document.removeEventListener('timeline:updateData', this.handleDataUpdate.bind(this))
}
handleDataUpdate(event) {
const { points, startTimestamp, endTimestamp } = event.detail
this.points = points || []
this.startTimestampValue = startTimestamp
this.endTimestampValue = endTimestamp
this.currentStartValue = startTimestamp
this.currentEndValue = endTimestamp
this.updateLabels()
this.draw()
}
draw() {
if (!this.hasCanvasTarget || this.points.length === 0) return
const canvas = this.canvasTarget
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
// Clear canvas
ctx.clearRect(0, 0, width, height)
// Draw background
ctx.fillStyle = '#1a1a2e'
ctx.fillRect(0, 0, width, height)
// Draw grid
this.drawGrid(ctx, width, height)
// Draw graph based on selected type
switch (this.graphType) {
case 'speed':
this.drawSpeedGraph(ctx, width, height)
break
case 'battery':
this.drawBatteryGraph(ctx, width, height)
break
case 'elevation':
this.drawElevationGraph(ctx, width, height)
break
}
// Draw time cursor
this.drawTimeCursor(ctx, width, height)
}
drawGrid(ctx, width, height) {
ctx.strokeStyle = '#2a2a3e'
ctx.lineWidth = 1
// Horizontal lines
const horizontalLines = 5
for (let i = 0; i <= horizontalLines; i++) {
const y = (height / horizontalLines) * i
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(width, y)
ctx.stroke()
}
// Vertical lines (time markers)
const verticalLines = 10
for (let i = 0; i <= verticalLines; i++) {
const x = (width / verticalLines) * i
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, height)
ctx.stroke()
}
}
drawSpeedGraph(ctx, width, height) {
if (this.points.length < 2) return
const timeRange = this.endTimestampValue - this.startTimestampValue
if (timeRange === 0) return
// Calculate speeds between consecutive points
const speeds = []
for (let i = 1; i < this.points.length; i++) {
const p1 = this.points[i - 1]
const p2 = this.points[i]
const timeDiff = p2.timestamp - p1.timestamp // seconds
if (timeDiff === 0) continue
// Calculate distance using Haversine formula
const distance = this.calculateDistance(
p1.latitude, p1.longitude,
p2.latitude, p2.longitude
)
// Speed in km/h
const speed = (distance / 1000) / (timeDiff / 3600)
speeds.push({
timestamp: p2.timestamp,
speed: Math.min(speed, 150) // Cap at 150 km/h for visualization
})
}
if (speeds.length === 0) return
// Find max speed for scaling
const maxSpeed = Math.max(...speeds.map(s => s.speed))
// Draw speed graph
ctx.strokeStyle = '#00ff88'
ctx.lineWidth = 2
ctx.beginPath()
speeds.forEach((item, index) => {
const x = ((item.timestamp - this.startTimestampValue) / timeRange) * width
const y = height - (item.speed / maxSpeed) * height * 0.9 // 90% of height
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// Draw speed labels
ctx.fillStyle = '#888'
ctx.font = '10px sans-serif'
ctx.fillText('0 km/h', 5, height - 5)
ctx.fillText(`${Math.round(maxSpeed)} km/h`, 5, 15)
}
drawBatteryGraph(ctx, width, height) {
if (this.points.length === 0) return
const timeRange = this.endTimestampValue - this.startTimestampValue
if (timeRange === 0) return
// Filter points with battery data
const batteryPoints = this.points.filter(p => p.battery !== null && p.battery !== undefined)
if (batteryPoints.length === 0) return
// Draw battery graph
ctx.strokeStyle = '#ffaa00'
ctx.lineWidth = 2
ctx.beginPath()
batteryPoints.forEach((point, index) => {
const x = ((point.timestamp - this.startTimestampValue) / timeRange) * width
const y = height - (point.battery / 100) * height * 0.9
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// Draw battery labels
ctx.fillStyle = '#888'
ctx.font = '10px sans-serif'
ctx.fillText('0%', 5, height - 5)
ctx.fillText('100%', 5, 15)
}
drawElevationGraph(ctx, width, height) {
if (this.points.length === 0) return
const timeRange = this.endTimestampValue - this.startTimestampValue
if (timeRange === 0) return
// Filter points with altitude data
const altitudePoints = this.points.filter(p => p.altitude !== null && p.altitude !== undefined)
if (altitudePoints.length === 0) return
// Find min/max altitude
const altitudes = altitudePoints.map(p => p.altitude)
const minAlt = Math.min(...altitudes)
const maxAlt = Math.max(...altitudes)
const altRange = maxAlt - minAlt || 1
// Draw elevation graph
ctx.strokeStyle = '#00aaff'
ctx.lineWidth = 2
ctx.beginPath()
altitudePoints.forEach((point, index) => {
const x = ((point.timestamp - this.startTimestampValue) / timeRange) * width
const y = height - ((point.altitude - minAlt) / altRange) * height * 0.9
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// Draw elevation labels
ctx.fillStyle = '#888'
ctx.font = '10px sans-serif'
ctx.fillText(`${Math.round(minAlt)}m`, 5, height - 5)
ctx.fillText(`${Math.round(maxAlt)}m`, 5, 15)
}
drawTimeCursor(ctx, width, height) {
const timeRange = this.endTimestampValue - this.startTimestampValue
if (timeRange === 0) return
const cursorX = ((this.currentStartValue - this.startTimestampValue) / timeRange) * width
// Draw vertical line
ctx.strokeStyle = '#ff0066'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(cursorX, 0)
ctx.lineTo(cursorX, height)
ctx.stroke()
// Draw time label
const date = new Date(this.currentStartValue * 1000)
const timeStr = date.toLocaleTimeString()
ctx.fillStyle = '#ff0066'
ctx.font = 'bold 12px sans-serif'
const textWidth = ctx.measureText(timeStr).width
const labelX = Math.min(Math.max(cursorX - textWidth / 2, 0), width - textWidth)
ctx.fillText(timeStr, labelX, height - 10)
}
// Haversine formula for distance calculation
calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371000 // Earth radius in meters
const φ1 = lat1 * Math.PI / 180
const φ2 = lat2 * Math.PI / 180
const Δφ = (lat2 - lat1) * Math.PI / 180
const Δλ = (lon2 - lon1) * Math.PI / 180
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
// Slider interaction
updateTimeFromSlider(event) {
const sliderValue = parseInt(event.target.value)
const timeRange = this.endTimestampValue - this.startTimestampValue
const newTimestamp = this.startTimestampValue + (sliderValue / 100) * timeRange
this.currentStartValue = newTimestamp
this.draw()
this.updateCurrentTimeLabel()
this.notifyMapController()
}
updateCurrentTimeLabel() {
if (!this.hasCurrentTimeTarget) return
const date = new Date(this.currentStartValue * 1000)
this.currentTimeTarget.textContent = date.toLocaleString()
}
updateLabels() {
if (this.hasStartLabelTarget) {
const startDate = new Date(this.startTimestampValue * 1000)
this.startLabelTarget.textContent = startDate.toLocaleDateString()
}
if (this.hasEndLabelTarget) {
const endDate = new Date(this.endTimestampValue * 1000)
this.endLabelTarget.textContent = endDate.toLocaleDateString()
}
}
changeGraphType(event) {
this.graphType = event.target.value
this.draw()
}
togglePlayback() {
this.isPlaying = !this.isPlaying
if (this.isPlaying) {
this.startPlayback()
if (this.hasPlayButtonTarget) {
this.playButtonTarget.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
`
}
} else {
this.stopPlayback()
if (this.hasPlayButtonTarget) {
this.playButtonTarget.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
`
}
}
}
startPlayback() {
const timeRange = this.endTimestampValue - this.startTimestampValue
const step = timeRange / 200 // 200 steps across the range
this.playbackInterval = setInterval(() => {
this.currentStartValue += step
if (this.currentStartValue >= this.endTimestampValue) {
this.currentStartValue = this.startTimestampValue // Loop back
}
// Update slider position
if (this.hasSliderTarget) {
const progress = ((this.currentStartValue - this.startTimestampValue) / timeRange) * 100
this.sliderTarget.value = progress
}
this.draw()
this.updateCurrentTimeLabel()
this.notifyMapController()
}, this.playbackSpeed / 200) // Smooth animation
}
stopPlayback() {
if (this.playbackInterval) {
clearInterval(this.playbackInterval)
this.playbackInterval = null
}
}
notifyMapController() {
// Emit event for map controller to filter points
const event = new CustomEvent('timeline:timeChanged', {
detail: {
currentTimestamp: this.currentStartValue,
startTimestamp: this.startTimestampValue,
endTimestamp: this.endTimestampValue
}
})
document.dispatchEvent(event)
}
// Public method to set data from map controller
setData(points, startTimestamp, endTimestamp) {
this.points = points
this.startTimestampValue = startTimestamp
this.endTimestampValue = endTimestamp
this.currentStartValue = startTimestamp
this.currentEndValue = endTimestamp
this.updateLabels()
this.draw()
}
}

View file

@ -0,0 +1,125 @@
<!-- Timeline component - positioned at bottom of map -->
<div class="timeline-container absolute bottom-0 left-0 right-0 z-10 bg-base-300 border-t border-base-content/10 hidden"
data-controller="maps--timeline"
data-maps--maplibre-target="timeline"
data-maps--timeline-start-timestamp-value="0"
data-maps--timeline-end-timestamp-value="0"
data-maps--timeline-current-start-value="0"
data-maps--timeline-current-end-value="0"
style="height: 200px;">
<!-- Timeline header with controls -->
<div class="timeline-header flex items-center justify-between px-4 py-2 border-b border-base-content/10">
<!-- Left side: Graph type selector -->
<div class="flex items-center gap-3">
<label class="label cursor-pointer gap-2">
<span class="label-text text-xs font-medium">Graph:</span>
<select class="select select-xs select-bordered"
data-maps--timeline-target="graphTypeSelect"
data-action="change->maps--timeline#changeGraphType">
<option value="speed">Speed</option>
<option value="battery">Battery</option>
<option value="elevation">Elevation</option>
</select>
</label>
</div>
<!-- Center: Current time display -->
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-base-content/60">Current:</span>
<span class="text-sm font-bold text-primary" data-maps--timeline-target="currentTime">--</span>
</div>
<!-- Right side: Play/pause button -->
<div class="flex items-center gap-3">
<button class="btn btn-sm btn-circle btn-primary"
data-maps--timeline-target="playButton"
data-action="click->maps--timeline#togglePlayback"
title="Play/Pause timeline animation">
<!-- Play icon (default) -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
</button>
<!-- Close timeline button -->
<button class="btn btn-sm btn-circle btn-ghost"
data-action="click->maps--maplibre#toggleTimeline"
title="Hide timeline">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<!-- Canvas for graph visualization -->
<div class="timeline-graph relative" style="height: 120px;">
<canvas data-maps--timeline-target="canvas"
class="w-full h-full"></canvas>
</div>
<!-- Timeline slider -->
<div class="timeline-slider px-4 py-3">
<div class="flex items-center gap-3">
<!-- Start date label -->
<span class="text-xs text-base-content/60 whitespace-nowrap"
data-maps--timeline-target="startLabel">--</span>
<!-- Range slider -->
<input type="range"
min="0"
max="100"
value="0"
step="0.1"
class="range range-primary range-xs flex-1"
data-maps--timeline-target="slider"
data-action="input->maps--timeline#updateTimeFromSlider" />
<!-- End date label -->
<span class="text-xs text-base-content/60 whitespace-nowrap"
data-maps--timeline-target="endLabel">--</span>
</div>
<!-- Timeline markers -->
<div class="flex justify-between text-xs text-base-content/40 mt-1 px-2">
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
</div>
</div>
</div>
<style>
.timeline-container {
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}
.timeline-graph canvas {
display: block;
}
/* Ensure timeline doesn't interfere with map controls */
.timeline-container {
pointer-events: auto;
}
/* Hidden state for timeline */
.timeline-container.hidden {
transform: translateY(100%);
transition: transform 0.3s ease-in-out;
}
.timeline-container:not(.hidden) {
transform: translateY(0);
transition: transform 0.3s ease-in-out;
}
</style>

View file

@ -21,13 +21,23 @@
</div>
<!-- Settings button (top-left corner) -->
<div class="absolute top-4 left-4 z-10">
<div class="absolute top-4 left-4 z-10 flex gap-2">
<button data-action="click->maps--maplibre#toggleSettings"
class="btn btn-sm btn-primary"
title="Open map settings">
<%= icon 'square-pen' %>
<span class="ml-1">Settings</span>
</button>
<button data-action="click->maps--maplibre#toggleTimeline"
data-maps--maplibre-target="timelineToggleButton"
class="btn btn-sm btn-primary"
title="Toggle timeline">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
<span class="ml-1">Timeline</span>
</button>
</div>
<!-- Loading overlay -->
@ -47,4 +57,7 @@
<!-- Place creation modal (shared) -->
<%= render 'shared/place_creation_modal' %>
<!-- Timeline component -->
<%= render 'map/maplibre/timeline' %>
</div>