mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
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:
parent
c8242ce902
commit
f1474d105b
5 changed files with 675 additions and 2 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
436
app/javascript/controllers/maps/timeline_controller.js
Normal file
436
app/javascript/controllers/maps/timeline_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
125
app/views/map/maplibre/_timeline.html.erb
Normal file
125
app/views/map/maplibre/_timeline.html.erb
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue