From f1474d105b95ed435b25bff6adcc09b9873fddad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 20:23:07 +0000 Subject: [PATCH] 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 --- .../maps/maplibre/map_data_manager.js | 8 + .../controllers/maps/maplibre_controller.js | 93 +++- .../controllers/maps/timeline_controller.js | 436 ++++++++++++++++++ app/views/map/maplibre/_timeline.html.erb | 125 +++++ app/views/map/maplibre/index.html.erb | 15 +- 5 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 app/javascript/controllers/maps/timeline_controller.js create mode 100644 app/views/map/maplibre/_timeline.html.erb diff --git a/app/javascript/controllers/maps/maplibre/map_data_manager.js b/app/javascript/controllers/maps/maplibre/map_data_manager.js index 88d13462..c8d33b60 100644 --- a/app/javascript/controllers/maps/maplibre/map_data_manager.js +++ b/app/javascript/controllers/maps/maplibre/map_data_manager.js @@ -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' diff --git a/app/javascript/controllers/maps/maplibre_controller.js b/app/javascript/controllers/maps/maplibre_controller.js index c9e5e1d6..0f706652 100644 --- a/app/javascript/controllers/maps/maplibre_controller.js +++ b/app/javascript/controllers/maps/maplibre_controller.js @@ -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 diff --git a/app/javascript/controllers/maps/timeline_controller.js b/app/javascript/controllers/maps/timeline_controller.js new file mode 100644 index 00000000..6df8abdd --- /dev/null +++ b/app/javascript/controllers/maps/timeline_controller.js @@ -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 = ` + + + + ` + } + } else { + this.stopPlayback() + if (this.hasPlayButtonTarget) { + this.playButtonTarget.innerHTML = ` + + + + ` + } + } + } + + 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() + } +} diff --git a/app/views/map/maplibre/_timeline.html.erb b/app/views/map/maplibre/_timeline.html.erb new file mode 100644 index 00000000..b34e54a2 --- /dev/null +++ b/app/views/map/maplibre/_timeline.html.erb @@ -0,0 +1,125 @@ + + + + diff --git a/app/views/map/maplibre/index.html.erb b/app/views/map/maplibre/index.html.erb index 961450b4..5f762258 100644 --- a/app/views/map/maplibre/index.html.erb +++ b/app/views/map/maplibre/index.html.erb @@ -21,13 +21,23 @@ -
+
+ +
@@ -47,4 +57,7 @@ <%= render 'shared/place_creation_modal' %> + + + <%= render 'map/maplibre/timeline' %>