From ffa2b6e5a6825a44fff2eb2d0afc28f7b8db5aa4 Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Thu, 27 Feb 2025 23:03:11 +0100
Subject: [PATCH] Support for distance and speed metrics on the timeline.

---
 frontend/src/components/Map.vue               |  50 +++-
 frontend/src/components/Timeline.vue          | 216 ++++++++++++++++--
 frontend/src/mixins/Geo.vue                   |  10 +-
 .../models/TimelineMetricsConfiguration.ts    |  55 +++++
 4 files changed, 306 insertions(+), 25 deletions(-)
 create mode 100644 frontend/src/models/TimelineMetricsConfiguration.ts

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;