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()
}
}