<template> <div class="timeline-container"> <h1 v-if="loading">Loading...</h1> <h1 v-else-if="!points.length">No data to display</h1> <div class="body" v-else> <div class="options"> <button @click="toggleMetric('altitude')" :class="{ selected: showMetrics.altitude }" :title="(showMetrics.altitude ? 'Hide' : 'Show') + ' altitude'"> <font-awesome-icon icon="mountain" /> </button> <button @click="toggleMetric('distance')" :class="{ selected: showMetrics.distance }" :title="(showMetrics.distance ? 'Hide' : 'Show') + ' distance'"> <font-awesome-icon icon="ruler" /> </button> <button @click="toggleMetric('speed')" :class="{ selected: showMetrics.speed }" :title="(showMetrics.speed ? 'Hide' : 'Show') + ' speed'"> <font-awesome-icon icon="tachometer-alt" /> </button> <button @click="toggleMetric('battery')" :class="{ selected: showMetrics.battery }" :title="(showMetrics.battery ? 'Hide' : 'Show') + ' battery level'" v-if="hasBatteryInfo"> <font-awesome-icon icon="battery-full" /> </button> </div> <div class="page-button-container"> <button @click="$emit('prev-page')" title="Previous results"> <font-awesome-icon icon="chevron-left" /> </button> </div> <div class="timeline"> <Line :data="graphData" :options="graphOptions" /> </div> <div class="page-button-container"> <button @click="$emit('next-page')" title="Next results"> <font-awesome-icon icon="chevron-right" /> </button> </div> <div class="page-button-container" v-if="locationQuery?.minId || locationQuery?.maxId"> <button @click="$emit('reset-page')" title="Reset pagination"> <font-awesome-icon icon="fas fa-undo" /> </button> </div> </div> </div> </template> <script lang="ts"> import { CategoryScale, Chart as ChartJS, LineElement, LinearScale, PointElement, TimeScale, Title, Tooltip, } from 'chart.js'; import { Line } from 'vue-chartjs'; import 'chartjs-adapter-date-fns'; import Geo from '../mixins/Geo.vue'; import GPSPoint from '../models/GPSPoint'; import LocationQuery from '../models/LocationQuery'; import TimelineMetricsConfiguration from '../models/TimelineMetricsConfiguration'; ChartJS.register( CategoryScale, LineElement, LinearScale, PointElement, TimeScale, Title, Tooltip, ); export default { emits: [ 'next-page', 'point-hover', 'prev-page', 'reset-page', 'show-metrics', ], mixins: [Geo], components: { Line, }, props: { loading: { type: Boolean, default: false, }, locationQuery: { type: LocationQuery, }, points: { type: Array as () => GPSPoint[], default: () => [], }, showMetrics: { type: TimelineMetricsConfiguration, default: () => new TimelineMetricsConfiguration(), }, }, computed: { distances(): number[] { if (!this.points.length) { return [] } return this.points.map((point: GPSPoint, index: number) => { if (index === 0) { return 0 } return this.latLngToDistance(point, this.points[index - 1]) }) }, hasBatteryInfo(): boolean { return this.points.some((point: GPSPoint) => point.battery != null) }, battery(): (number | null)[] { return this.points .map((point: GPSPoint) => point.battery ?? null) }, speed(): number[] { if (!this.points.length) { return [] } return this.points.map((point: GPSPoint, index: number) => { if (point.speed != null) { return point.speed } if (index === 0) { return 0 } const distance = this.latLngToDistance(point, this.points[index - 1]) const time = point.timestamp.getTime() - this.points[index - 1].timestamp.getTime() return 3.6 * distance / (time / 1000) }) }, graphData() { const datasets = [] if (this.showMetrics.altitude) { datasets.push( { label: 'Altitude (m)', backgroundColor: '#7979f8', borderColor: '#5959a8', fill: false, data: this.points.map((point: GPSPoint) => point.altitude), yAxisID: 'meters', } ) } if (this.showMetrics.distance) { datasets.push( { label: 'Distance (' + (this.showMetrics.altitude ? '' : 'k') + 'm)', backgroundColor: '#f87979', borderColor: '#a85959', fill: false, data: this.showMetrics.altitude ? this.distances : this.distances.map((distance: number) => distance / 1000), yAxisID: 'meters', } ) } if (this.showMetrics.speed) { datasets.push( { label: 'Speed (km/h)', backgroundColor: '#79f879', borderColor: '#59a859', fill: false, data: this.speed, yAxisID: 'speed', } ) } if (this.showMetrics.battery) { datasets.push( { label: 'Battery (%)', backgroundColor: '#0989f8', borderColor: '#5959a8', fill: false, data: this.battery, yAxisID: 'percentage', } ) } return { labels: this.points.map((point: GPSPoint) => point.timestamp), datasets: datasets, } }, graphOptions(): any { const yAxes: Record<string, any> = [] if (this.showMetrics.altitude || this.showMetrics.distance) { const text: string[] = [] if (this.showMetrics.altitude) { text.push('Altitude') } if (this.showMetrics.distance) { text.push('Distance') } const unit = this.showMetrics.altitude ? 'm' : 'km' yAxes.meters = { type: 'linear', position: 'left', display: true, ticks: { beginAtZero: !this.showMetrics.distance, }, title: { display: true, text: text.join(' / ') + ` (${unit})`, } } } if (this.showMetrics.speed) { yAxes.speed = { type: 'linear', position: yAxes.meters ? 'right' : 'left', display: true, ticks: { beginAtZero: true, }, title: { display: true, text: 'Speed (km/h)', }, ...( yAxes.length ? { grid: { // We only want the grid lines for one axis to show up drawOnChartArea: false, }, } : {} ) } } if (this.showMetrics.battery) { let position = 'left' if (yAxes.meters || yAxes.speed) { position = 'right' } yAxes.percentage = { type: 'linear', position: position, display: true, min: 0, max: 100, ticks: { beginAtZero: true, }, title: { display: true, text: 'Percentage', }, grid: { drawOnChartArea: false, }, } } const xTicks = {} as { min?: Date, max?: Date } if (this.points.length > 1) { xTicks.min = this.points[0].timestamp xTicks.max = this.points[this.points.length - 1].timestamp } return { responsive: true, maintainAspectRatio: false, stacked: false, elements: { point: { borderWidth: 0, hoverRadius: 4, hoverBorderWidth: 2, }, line: { tension: 0.5, borderWidth: 1, fill: false, } }, interaction: { mode: 'index', intersect: false, }, onHover: (_: MouseEvent, activeElements: any) => { if (activeElements.length) { const index = activeElements[0].index; const point = this.points[index]; this.$emit('point-hover', point); } }, scales: { x: { type: 'time', grid: { drawOnChartArea: true, drawTicks: true, }, time: { tooltipFormat: 'MMM dd yyyy, HH:mm', }, ticks: xTicks, title: { display: true, text: 'Date' }, }, ...yAxes, } } }, }, methods: { toggleMetric(metric: string) { this.$emit('show-metrics', { ...this.showMetrics, [metric]: !(this.showMetrics as any)[metric], }); }, }, } </script> <style scoped lang="scss"> $options-width: 5em; .timeline-container { display: flex; justify-content: center; align-items: center; height: 100%; width: 100%; } .body { display: flex; flex-direction: row; height: 100%; width: 100%; } .timeline { width: calc(100% - #{$options-width}); height: 100%; canvas { width: 100% !important; height: 100% !important; } } .options { display: flex; flex-direction: column; justify-content: space-around; align-items: center; width: $options-width; height: 100%; margin-right: 1em; button { width: 100%; height: 2.5em; font-size: 1em; background-color: var(--color-background); border: 1px solid var(--vt-c-divider-light-1); margin-left: 0.5em; cursor: pointer; &:hover { color: var(--color-hover); } &.selected { background: var(--vt-c-blue-bg-dark); color: var(--vt-c-white); } } } .page-button-container { width: 3em; height: 100%; display: flex; justify-content: center; align-items: center; button { width: 100%; height: 100%; font-size: 1em; background-color: var(--color-background); border: 0; margin-left: 0.5em; cursor: pointer; &:hover { color: var(--color-hover); } } } </style>