diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 04dbfc3..2133a86 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -13,10 +13,13 @@
         "@fortawesome/free-regular-svg-icons": "^6.7.2",
         "@fortawesome/free-solid-svg-icons": "^6.7.2",
         "@fortawesome/vue-fontawesome": "^3.0.8",
+        "chart.js": "^4.4.8",
+        "chartjs-adapter-date-fns": "^3.0.0",
         "countries-list": "^3.1.1",
         "lodash": "^4.17.21",
         "ol": "^10.4.0",
         "vue": "^3.5.13",
+        "vue-chartjs": "^5.3.2",
         "vue-router": "^4.5.0"
       },
       "devDependencies": {
@@ -1328,6 +1331,12 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@kurkle/color": {
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+      "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+      "license": "MIT"
+    },
     "node_modules/@nodelib/fs.scandir": {
       "version": "2.1.5",
       "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2531,6 +2540,28 @@
         "url": "https://github.com/chalk/chalk?sponsor=1"
       }
     },
+    "node_modules/chart.js": {
+      "version": "4.4.8",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz",
+      "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==",
+      "license": "MIT",
+      "dependencies": {
+        "@kurkle/color": "^0.3.0"
+      },
+      "engines": {
+        "pnpm": ">=8"
+      }
+    },
+    "node_modules/chartjs-adapter-date-fns": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
+      "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "chart.js": ">=2.8.0",
+        "date-fns": ">=2.0.0"
+      }
+    },
     "node_modules/color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2662,6 +2693,17 @@
       "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
       "license": "MIT"
     },
+    "node_modules/date-fns": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+      "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/kossnocorp"
+      }
+    },
     "node_modules/de-indent": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -5556,6 +5598,16 @@
         }
       }
     },
+    "node_modules/vue-chartjs": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
+      "integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "chart.js": "^4.1.1",
+        "vue": "^3.0.0-0 || ^2.7.0"
+      }
+    },
     "node_modules/vue-eslint-parser": {
       "version": "9.4.3",
       "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 76e0c80..3c8a7f9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,10 +18,13 @@
     "@fortawesome/free-regular-svg-icons": "^6.7.2",
     "@fortawesome/free-solid-svg-icons": "^6.7.2",
     "@fortawesome/vue-fontawesome": "^3.0.8",
+    "chart.js": "^4.4.8",
+    "chartjs-adapter-date-fns": "^3.0.0",
     "countries-list": "^3.1.1",
     "lodash": "^4.17.21",
     "ol": "^10.4.0",
     "vue": "^3.5.13",
+    "vue-chartjs": "^5.3.2",
     "vue-router": "^4.5.0"
   },
   "devDependencies": {
diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css
index 051fbd5..3d899a5 100644
--- a/frontend/src/assets/main.css
+++ b/frontend/src/assets/main.css
@@ -1,7 +1,8 @@
 @import './base.css';
 
 #app {
-  max-width: 1280px;
+  width: 100%;
+  height: 100vh;
   font-weight: normal;
 }
 
diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue
index 0aa855a..95545d0 100644
--- a/frontend/src/components/Map.vue
+++ b/frontend/src/components/Map.vue
@@ -1,7 +1,7 @@
 <template>
   <main>
     <div class="loading" v-if="loading">Loading...</div>
-    <div class="map-wrapper" v-else>
+    <div class="map-body" v-else>
       <div id="map">
         <div class="time-range" v-if="oldestPoint && newestPoint">
           <div class="row">
@@ -46,6 +46,12 @@
                         :value="showControls" />
         </div>
       </div>
+
+      <div class="timeline">
+        <Timeline :loading="loading"
+                  :points="gpsPoints"
+                  @point-hover="highlightPoint(pointsLayer, $event)" />
+      </div>
     </div>
   </main>
 </template>
@@ -70,6 +76,7 @@ import MapView from '../mixins/MapView.vue';
 import Paginate from '../mixins/Paginate.vue';
 import Points from '../mixins/Points.vue';
 import Routes from '../mixins/Routes.vue';
+import Timeline from './Timeline.vue';
 import URLQueryHandler from '../mixins/URLQueryHandler.vue';
 
 useGeographic()
@@ -89,13 +96,13 @@ export default {
     FilterButton,
     FilterForm,
     PointInfo,
+    Timeline,
   },
 
   data() {
     return {
       loading: false,
       map: null as Nullable<Map>,
-      mappedPoints: [] as Point[],
       mapView: null as Nullable<View>,
       pointsLayer: null as Nullable<VectorLayer>,
       popup: null as Nullable<Overlay>,
@@ -106,6 +113,21 @@ export default {
     }
   },
 
+  computed: {
+    groupedGPSPoints(): GPSPoint[] {
+      return this.groupPoints(this.gpsPoints)
+    },
+
+    mappedPoints(): Record<string, Point> {
+      return this.toMappedPoints(this.groupedGPSPoints)
+        .reduce((acc: Record<string, Point>, point: Point) => {
+          // @ts-expect-error
+          acc[point.values_.id] = point
+          return acc
+        }, {})
+    },
+  },
+
   methods: {
     async fetch(): Promise<GPSPoint[]> {
       this.loading = true
@@ -137,11 +159,10 @@ export default {
       this.locationQuery = prevPageQuery
     },
 
-    createMap(gpsPoints: GPSPoint[]): Map {
-      this.mappedPoints = this.toMappedPoints(gpsPoints)
-      this.pointsLayer = this.createPointsLayer(this.mappedPoints as Point[])
-      this.routesLayer = this.createRoutesLayer(this.mappedPoints as Point[])
-      this.mapView = this.mapView || this.createMapView(gpsPoints)
+    createMap(): Map {
+      this.pointsLayer = this.createPointsLayer(Object.values(this.mappedPoints) as Point[])
+      this.routesLayer = this.createRoutesLayer(Object.values(this.mappedPoints) as Point[])
+      this.mapView = this.mapView || this.createMapView(this.gpsPoints)
       const map = new Map({
         target: 'map',
         layers: [
@@ -254,14 +275,13 @@ export default {
           this.hasPrevPage = gpsPoints.length > 1
         }
 
-        this.mappedPoints = this.toMappedPoints(this.gpsPoints)
         if (this.mapView) {
           // @ts-ignore
           this.refreshMapView(this.mapView, this.gpsPoints)
           // @ts-ignore
-          this.refreshPointsLayer(this.pointsLayer, this.mappedPoints)
+          this.refreshPointsLayer(this.pointsLayer, Object.values(this.mappedPoints))
           // @ts-ignore
-          this.refreshRoutesLayer(this.routesLayer, this.mappedPoints)
+          this.refreshRoutesLayer(this.routesLayer, Object.values(this.mappedPoints))
         }
       },
       deep: true,
@@ -271,7 +291,7 @@ export default {
   async mounted() {
     this.initQuery()
     this.gpsPoints = await this.fetch()
-    this.map = this.createMap(this.gpsPoints)
+    this.map = this.createMap()
   },
 }
 </script>
@@ -280,17 +300,33 @@ export default {
 @use "@/styles/common.scss" as *;
 @import "ol/ol.css";
 
+$timeline-height: 7.5em;
+
 html,
 body {
   margin: 0;
   height: 100%;
 }
 
+main {
+  width: 100%;
+  height: 100%;
+}
+
+.map-body {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+}
+
 #map {
   position: absolute;
   top: 0;
   bottom: 0;
   width: 100%;
+  height: calc(100% - #{$timeline-height});
 
   .controls {
     position: absolute;
@@ -336,6 +372,17 @@ body {
   }
 }
 
+.timeline {
+  width: 100%;
+  height: $timeline-height;
+  position: absolute;
+  bottom: 0;
+  background-color: var(--color-background);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
 @keyframes unroll {
   from {
     transform: translateY(7.5em);
diff --git a/frontend/src/components/Timeline.vue b/frontend/src/components/Timeline.vue
new file mode 100644
index 0000000..051644c
--- /dev/null
+++ b/frontend/src/components/Timeline.vue
@@ -0,0 +1,146 @@
+<template>
+  <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>
+  </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 GPSPoint from '../models/GPSPoint';
+
+ChartJS.register(
+  CategoryScale,
+  LineElement,
+  LinearScale,
+  PointElement,
+  TimeScale,
+  Title,
+  Tooltip,
+);
+
+export default {
+  emits: ['point-hover'],
+  components: {
+    Line,
+  },
+
+  props: {
+    loading: {
+      type: Boolean,
+      default: false,
+    },
+    points: {
+      type: Array as () => GPSPoint[],
+      default: () => [],
+    },
+  },
+
+  computed: {
+    graphData() {
+      return {
+        labels: this.points.map((point: GPSPoint) => point.timestamp),
+        datasets: [
+          {
+            label: 'Altitude (m)',
+            backgroundColor: '#7979f8',
+            borderColor: '#5959a8',
+            fill: false,
+            data: this.points.map((point: GPSPoint) => point.altitude),
+          }
+        ]
+      }
+    },
+
+    graphOptions() {
+      return {
+        responsive: true,
+        maintainAspectRatio: false,
+        elements: {
+          point: {
+            borderWidth: 1,
+            hoverRadius: 4,
+            hoverBorderWidth: 2,
+          },
+          line: {
+            tension: 0.5,
+            borderWidth: 2,
+            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, HH:mm',
+              unit: 'minute',
+            },
+            title: {
+              display: true,
+              text: 'Date'
+            },
+          },
+          y: {
+            beginAtZero: true,
+            title: {
+              display: true,
+              text: 'Altitude (m)'
+            },
+          }
+        }
+      }
+    },
+  },
+}
+</script>
+
+<style scoped lang="scss">
+.timeline-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100%;
+  width: 100%;
+}
+
+.timeline {
+  width: 100%;
+  height: 100%;
+
+  canvas {
+    width: 100% !important;
+    height: 100% !important;
+  }
+}
+</style>
diff --git a/frontend/src/mixins/Points.vue b/frontend/src/mixins/Points.vue
index 9a0c0a6..7b21013 100644
--- a/frontend/src/mixins/Points.vue
+++ b/frontend/src/mixins/Points.vue
@@ -16,16 +16,38 @@ const maxZoom = 18
 
 useGeographic()
 
+const pointStyles = {
+  default: new Style({
+    image: new Circle({
+      radius: 6,
+      fill: new Fill({ color: 'aquamarine' }),
+      stroke: new Stroke({ color: 'blue', width: 1 }),
+    }),
+    zIndex: Infinity,  // Ensure that points are always displayed above other layers
+  }),
+
+  highlighted: new Style({
+    image: new Circle({
+      radius: 10,
+      fill: new Fill({ color: 'rgba(255, 0, 0, 0.5)' }),
+      stroke: new Stroke({ color: '#FF0000', width: 2 }),
+    }),
+    zIndex: Infinity,  // Ensure that points are always displayed above other layers
+  }),
+}
+
 export default {
   mixins: [Geo, Units],
   data() {
     return {
       metersTolerance: 20,
+      highlightedPointId: null,
+      highlightedFeature: null,
     }
   },
 
   methods: {
-    groupPoints(points: GPSPoint[]) {
+    groupPoints(points: GPSPoint[]): GPSPoint[] {
       if (!points.length) {
         return []
       }
@@ -93,8 +115,12 @@ export default {
     },
 
     toMappedPoints(gpsPoints: GPSPoint[]): Point[] {
-      return this.groupPoints(gpsPoints).map(
-        (gps: GPSPoint) => new Point([gps.longitude, gps.latitude])
+      return gpsPoints.map(
+        (gps: GPSPoint) => {
+          const point = new Point([gps.longitude, gps.latitude])
+          point.setProperties(gps)
+          return point
+        }
       )
     },
 
@@ -143,6 +169,27 @@ export default {
         }
       })
     },
+
+    highlightPoint(layer: VectorLayer, point: GPSPoint) {
+      const feature = layer.getSource().getClosestFeatureToCoordinate([point.longitude, point.latitude])
+      if (feature) {
+        if (point.id === this.highlightedPointId) {
+          return
+        }
+
+        // Reset the previous highlighted point, if any
+        if (this.highlightedPointId) {
+          const prevFeature = this.highlightedFeature
+          if (prevFeature) {
+            prevFeature.setStyle(pointStyles.default)
+          }
+        }
+
+        feature.setStyle(pointStyles.highlighted)
+        this.highlightedPointId = point.id
+        this.highlightedFeature = feature
+      }
+    },
   },
 }
 </script>
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue
index 8ea3bb4..1728217 100644
--- a/frontend/src/views/HomeView.vue
+++ b/frontend/src/views/HomeView.vue
@@ -13,3 +13,12 @@ export default {
     <Map />
   </main>
 </template>
+
+<style lang="scss">
+main {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  width: 100%;
+}
+</style>