diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue index 91ee71f..d42978c 100644 --- a/frontend/src/components/Map.vue +++ b/frontend/src/components/Map.vue @@ -50,13 +50,16 @@ <div class="timeline"> <Timeline :loading="loading" :points="gpsPoints" - @point-hover="onTimelinePointHover" /> + :show-metrics="showMetrics" + @point-hover="onTimelinePointHover" + @show-metrics="setShowMetrics" /> </div> </div> </main> </template> <script lang="ts"> +import _ from 'lodash'; import Map from 'ol/Map'; import Overlay from 'ol/Overlay'; import Point from 'ol/geom/Point'; @@ -77,6 +80,7 @@ import Paginate from '../mixins/Paginate.vue'; import Points from '../mixins/Points.vue'; import Routes from '../mixins/Routes.vue'; import Timeline from './Timeline.vue'; +import TimelineMetricsConfiguration from '../models/TimelineMetricsConfiguration'; import URLQueryHandler from '../mixins/URLQueryHandler.vue'; useGeographic() @@ -110,6 +114,7 @@ export default { routesLayer: null as Nullable<VectorLayer>, selectedPoint: null as Nullable<GPSPoint>, showControls: false, + showMetrics: new TimelineMetricsConfiguration(), } }, @@ -207,13 +212,24 @@ export default { }) }, + refreshShowMetricsFromURL() { + this.showMetrics = new TimelineMetricsConfiguration(this.parseQuery(window.location.href)) + }, + initQuery() { + this.refreshShowMetricsFromURL() + const urlQuery = this.parseQuery(window.location.href) - if (!Object.keys(urlQuery).length) { - this.setQuery(this.locationQuery) - } else { + if (Object.keys(urlQuery).length) { this.locationQuery = new LocationQuery(urlQuery) } + + this.setQuery( + { + ...this.locationQuery, + ...this.showMetrics.toQuery(), + } + ) }, onStartDateClick() { @@ -235,6 +251,10 @@ export default { this.highlightPoint(this.pointsLayer as VectorLayer, point) }, + + setShowMetrics(metrics: any) { + Object.assign(this.showMetrics, metrics) + }, }, watch: { @@ -255,7 +275,13 @@ export default { // Results with maxId should be retrieved in descending order, // otherwise all results should be retrieved in ascending order newQuery.order = newQuery.maxId ? 'desc' : 'asc' - this.setQuery(newQuery) + this.setQuery( + { + ...newQuery, + ...this.showMetrics.toQuery(), + } + ) + this.queryInitialized = true if (!isFirstQuery) { @@ -294,6 +320,16 @@ export default { }, deep: true, }, + + showMetrics: { + handler() { + this.setQuery({ + ...this.locationQuery, + ...this.showMetrics.toQuery(), + }) + }, + deep: true, + }, }, async mounted() { @@ -308,7 +344,7 @@ export default { @use "@/styles/common.scss" as *; @import "ol/ol.css"; -$timeline-height: 7.5em; +$timeline-height: 10em; html, body { @@ -389,6 +425,8 @@ main { display: flex; justify-content: center; align-items: center; + padding-top: 0.5em; + box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.5); } @keyframes unroll { diff --git a/frontend/src/components/Timeline.vue b/frontend/src/components/Timeline.vue index 81c30f9..6dca8d8 100644 --- a/frontend/src/components/Timeline.vue +++ b/frontend/src/components/Timeline.vue @@ -2,8 +2,30 @@ <div class="timeline-container"> <h1 v-if="loading">Loading...</h1> <h1 v-else-if="!points.length">No data to display</h1> - <div class="timeline" v-else> - <Line :data="graphData" :options="graphOptions" /> + <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="ruler-vertical" /> + </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> + </div> + + <div class="timeline"> + <Line :data="graphData" :options="graphOptions" /> + </div> </div> </div> </template> @@ -12,7 +34,6 @@ import { CategoryScale, Chart as ChartJS, - type ChartOptions, LineElement, LinearScale, PointElement, @@ -24,7 +45,9 @@ import { import { Line } from 'vue-chartjs'; import 'chartjs-adapter-date-fns'; +import Geo from '../mixins/Geo.vue'; import GPSPoint from '../models/GPSPoint'; +import TimelineMetricsConfiguration from '../models/TimelineMetricsConfiguration'; ChartJS.register( CategoryScale, @@ -37,7 +60,8 @@ ChartJS.register( ); export default { - emits: ['point-hover'], + emits: ['point-hover', 'show-metrics'], + mixins: [Geo], components: { Line, }, @@ -51,13 +75,47 @@ export default { 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]) + }) + }, + + speed(): number[] { + if (!this.points.length) { + return [] + } + + return this.points.map((point: GPSPoint, index: number) => { + 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() { - return { - labels: this.points.map((point: GPSPoint) => point.timestamp), - datasets: [ + const datasets = [] + if (this.showMetrics.altitude) { + datasets.push( { label: 'Altitude (m)', backgroundColor: '#7979f8', @@ -65,23 +123,104 @@ export default { fill: false, data: this.points.map((point: GPSPoint) => point.altitude), } - ] + ) + } + + if (this.showMetrics.distance) { + datasets.push( + { + label: 'Distance (m)', + backgroundColor: '#f87979', + borderColor: '#a85959', + fill: false, + data: this.distances, + yAxisID: 'meters', + } + ) + } + + if (this.showMetrics.speed) { + datasets.push( + { + label: 'Speed (km/h)', + backgroundColor: '#79f879', + borderColor: '#59a859', + fill: false, + data: this.speed, + yAxisID: 'speed', + } + ) + } + + 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') + } + + yAxes.meters = { + type: 'linear', + position: 'left', + display: true, + ticks: { + beginAtZero: !this.showMetrics.distance, + }, + title: { + display: true, + text: text.join(' / ') + ' (m)', + } + } + } + + 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, + }, + } : {} + ) + } + } + return { responsive: true, maintainAspectRatio: false, + stacked: false, elements: { point: { - borderWidth: 1, + borderWidth: 0, hoverRadius: 4, hoverBorderWidth: 2, }, line: { tension: 0.5, - borderWidth: 2, + borderWidth: 1, fill: false, } }, @@ -112,21 +251,26 @@ export default { text: 'Date' }, }, - y: { - beginAtZero: true, - title: { - display: true, - text: 'Altitude (m)' - }, - } + ...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; @@ -135,8 +279,15 @@ export default { width: 100%; } -.timeline { +.body { + display: flex; + flex-direction: row; + height: 100%; width: 100%; +} + +.timeline { + width: calc(100% - #{$options-width}); height: 100%; canvas { @@ -144,4 +295,33 @@ export default { 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); + } + } +} </style> diff --git a/frontend/src/mixins/Geo.vue b/frontend/src/mixins/Geo.vue index 452d22c..d79b556 100644 --- a/frontend/src/mixins/Geo.vue +++ b/frontend/src/mixins/Geo.vue @@ -15,7 +15,15 @@ export default { Math.sin(Δλ / 2) * Math.sin(Δλ / 2) const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) - return R * c // in metres + const dx = R * c // in metres + + // Take altitude into account if available for both points + if (p.altitude && q.altitude) { + const dy = p.altitude - q.altitude + return Math.sqrt(dx * dx + dy * dy) + } + + return dx }, }, } diff --git a/frontend/src/models/TimelineMetricsConfiguration.ts b/frontend/src/models/TimelineMetricsConfiguration.ts new file mode 100644 index 0000000..5794771 --- /dev/null +++ b/frontend/src/models/TimelineMetricsConfiguration.ts @@ -0,0 +1,55 @@ +class TimelineMetricsConfiguration { + public altitude: boolean = false; + public distance: boolean = true; + public speed: boolean = false; + + constructor(data: any | null = null) { + if (!data) { + return; + } + + for (const key of ['altitude', 'distance', 'speed']) { + const value = String( + data[key] ?? data['show' + key.charAt(0).toUpperCase() + key.slice(1)] + ) + + switch (value) { + case '1': + case 'true': + // @ts-expect-error + this[key] = true; + break; + case '0': + case 'false': + // @ts-expect-error + this[key] = false; + break; + } + } + } + + toggleMetric(metric: string) { + switch (metric) { + case 'altitude': + this.altitude = !this.altitude; + break; + case 'distance': + this.distance = !this.distance; + break; + case 'speed': + this.speed = !this.speed; + break; + default: + throw new TypeError(`Invalid timeline metric: ${metric}`); + } + } + + toQuery(): Record<string, string> { + return ['altitude', 'distance', 'speed'].reduce((acc: Record<string, string>, key: string) => { + acc['show' + key.charAt(0).toUpperCase() + key.slice(1)] = String((this as any)[key]); + return acc; + }, {}); + } +} + +export default TimelineMetricsConfiguration;