From 0a1e6fcf1917881c9fce3cbac38a5416cdc3dc0f Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Sun, 23 Feb 2025 20:43:03 +0100
Subject: [PATCH] Advanced filters form + split Map component into functional
 mixins.

---
 frontend/package-lock.json                    |  74 ++++-
 frontend/package.json                         |   6 +
 frontend/src/assets/base.css                  |   1 +
 frontend/src/components/Map.vue               | 259 ++++++---------
 frontend/src/components/filter/Form.vue       | 308 ++++++++++++++++++
 .../src/components/filter/ToggleButton.vue    |  54 +++
 frontend/src/main.ts                          |  20 +-
 frontend/src/mixins/Api.vue                   |  34 ++
 frontend/src/mixins/Geo.vue                   |  22 ++
 frontend/src/mixins/MapView.vue               |  32 ++
 frontend/src/mixins/Points.vue                | 125 +++++++
 frontend/src/mixins/Routes.vue                |  53 +++
 frontend/src/mixins/URLQueryHandler.vue       |  90 +++++
 frontend/src/models/LocationQuery.ts          |  31 ++
 frontend/src/styles/common.scss               |  24 ++
 src/helpers/logging.ts                        |   2 +-
 16 files changed, 974 insertions(+), 161 deletions(-)
 create mode 100644 frontend/src/components/filter/Form.vue
 create mode 100644 frontend/src/components/filter/ToggleButton.vue
 create mode 100644 frontend/src/mixins/Api.vue
 create mode 100644 frontend/src/mixins/Geo.vue
 create mode 100644 frontend/src/mixins/MapView.vue
 create mode 100644 frontend/src/mixins/Points.vue
 create mode 100644 frontend/src/mixins/Routes.vue
 create mode 100644 frontend/src/mixins/URLQueryHandler.vue
 create mode 100644 frontend/src/models/LocationQuery.ts
 create mode 100644 frontend/src/styles/common.scss

diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 36f135b..54592bd 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,7 +8,13 @@
       "name": "gpstracker",
       "version": "0.0.0",
       "dependencies": {
+        "@fortawesome/fontawesome-svg-core": "^6.7.2",
+        "@fortawesome/free-brands-svg-icons": "^6.7.2",
+        "@fortawesome/free-regular-svg-icons": "^6.7.2",
+        "@fortawesome/free-solid-svg-icons": "^6.7.2",
+        "@fortawesome/vue-fontawesome": "^3.0.8",
         "countries-list": "^3.1.1",
+        "lodash": "^4.17.21",
         "ol": "^10.4.0",
         "vue": "^3.5.13",
         "vue-router": "^4.5.0"
@@ -1136,6 +1142,73 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@fortawesome/fontawesome-common-types": {
+      "version": "6.7.2",
+      "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz",
+      "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/@fortawesome/fontawesome-svg-core": {
+      "version": "6.7.2",
+      "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
+      "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
+      "license": "MIT",
+      "dependencies": {
+        "@fortawesome/fontawesome-common-types": "6.7.2"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/@fortawesome/free-brands-svg-icons": {
+      "version": "6.7.2",
+      "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz",
+      "integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==",
+      "license": "(CC-BY-4.0 AND MIT)",
+      "dependencies": {
+        "@fortawesome/fontawesome-common-types": "6.7.2"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/@fortawesome/free-regular-svg-icons": {
+      "version": "6.7.2",
+      "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
+      "integrity": "sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==",
+      "license": "(CC-BY-4.0 AND MIT)",
+      "dependencies": {
+        "@fortawesome/fontawesome-common-types": "6.7.2"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/@fortawesome/free-solid-svg-icons": {
+      "version": "6.7.2",
+      "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
+      "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==",
+      "license": "(CC-BY-4.0 AND MIT)",
+      "dependencies": {
+        "@fortawesome/fontawesome-common-types": "6.7.2"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/@fortawesome/vue-fontawesome": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.8.tgz",
+      "integrity": "sha512-yyHHAj4G8pQIDfaIsMvQpwKMboIZtcHTUvPqXjOHyldh1O1vZfH4W03VDPv5RvI9P6DLTzJQlmVgj9wCf7c2Fw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@fortawesome/fontawesome-svg-core": "~1 || ~6",
+        "vue": ">= 3.0.0 < 4"
+      }
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3733,7 +3806,6 @@
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/lodash.merge": {
diff --git a/frontend/package.json b/frontend/package.json
index 201e7b7..abce684 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,7 +13,13 @@
     "format": "prettier --write src/"
   },
   "dependencies": {
+    "@fortawesome/fontawesome-svg-core": "^6.7.2",
+    "@fortawesome/free-brands-svg-icons": "^6.7.2",
+    "@fortawesome/free-regular-svg-icons": "^6.7.2",
+    "@fortawesome/free-solid-svg-icons": "^6.7.2",
+    "@fortawesome/vue-fontawesome": "^3.0.8",
     "countries-list": "^3.1.1",
+    "lodash": "^4.17.21",
     "ol": "^10.4.0",
     "vue": "^3.5.13",
     "vue-router": "^4.5.0"
diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css
index 86ae22f..0c7edbc 100644
--- a/frontend/src/assets/base.css
+++ b/frontend/src/assets/base.css
@@ -74,6 +74,7 @@
   --color-heading: var(--vt-c-text-light-1);
   --color-text: var(--vt-c-text-light-1);
   --color-accent: var(--vt-c-blue-fg-light);
+  --color-hover: var(--vt-c-blue-fg-dark);
 
   --section-gap: 160px;
 }
diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue
index f04dedc..4822a7b 100644
--- a/frontend/src/components/Map.vue
+++ b/frontend/src/components/Map.vue
@@ -3,35 +3,56 @@
     <div class="loading" v-if="loading">Loading...</div>
     <div class="map-wrapper" v-else>
       <div id="map">
-        <PointInfo :point="selectedPoint" ref="popup" @close="selectedPoint = null" />
+        <PointInfo :point="selectedPoint"
+                   ref="popup"
+                   @close="selectedPoint = null" />
+
+        <div class="controls">
+          <div class="form-container" v-if="showControls">
+            <FilterForm :value="locationQuery" @refresh="locationQuery = $event" />
+          </div>
+          <FilterButton @input="showControls = !showControls"
+                        :value="showControls" />
+        </div>
       </div>
     </div>
   </main>
 </template>
 
 <script lang="ts">
-import Feature from 'ol/Feature';
-import GPSPoint from '../models/GPSPoint';
 import Map from 'ol/Map';
-import LineString from 'ol/geom/LineString';
-import OSM from 'ol/source/OSM';
 import Overlay from 'ol/Overlay';
 import Point from 'ol/geom/Point';
 import PointInfo from './PointInfo.vue';
 import VectorLayer from 'ol/layer/Vector';
-import VectorSource from 'ol/source/Vector';
 import View from 'ol/View';
-import TileLayer from 'ol/layer/Tile';
-import { Circle, Fill, Style, Stroke } from 'ol/style';
 import { useGeographic } from 'ol/proj';
-import type { Nullable } from '../models/Types';
 
-// @ts-ignore
-const baseURL = __API_PATH__
+import type { Nullable } from '../models/Types';
+import Api from '../mixins/Api.vue';
+import FilterButton from './filter/ToggleButton.vue';
+import FilterForm from './filter/Form.vue';
+import GPSPoint from '../models/GPSPoint';
+import LocationQuery from '../models/LocationQuery';
+import MapView from '../mixins/MapView.vue';
+import Points from '../mixins/Points.vue';
+import Routes from '../mixins/Routes.vue';
+import URLQueryHandler from '../mixins/URLQueryHandler.vue';
+
 useGeographic()
 
 export default {
+  mixins: [
+    Api,
+    MapView,
+    Points,
+    Routes,
+    URLQueryHandler,
+  ],
+
   components: {
+    FilterButton,
+    FilterForm,
     PointInfo,
   },
 
@@ -39,159 +60,57 @@ export default {
     return {
       gpsPoints: [] as GPSPoint[],
       loading: false,
+      locationQuery: new LocationQuery({}),
       map: null as Nullable<Map>,
+      mapView: null as Nullable<View>,
+      pointsLayer: null as Nullable<VectorLayer>,
       popup: null as Nullable<Overlay>,
-      routeFeatures: [] as Feature[],
+      routesLayer: null as Nullable<VectorLayer>,
       selectedPoint: null as Nullable<GPSPoint>,
-      latlngTolerance: 0.001,
+      showControls: false,
     }
   },
 
   methods: {
-    async fetchPoints() {
+    async fetch(): Promise<GPSPoint[]> {
       this.loading = true
       try {
-        const response = await fetch(`${baseURL}/gpsdata`)
-        return (await response.json())
-          .map((gps: any) => {
-            return new GPSPoint(gps)
-          })
+        return this.fetchPoints(this.locationQuery)
       } catch (error) {
         console.error(error)
+        return []
       } finally {
         this.loading = false
       }
     },
 
-    groupPoints(points: GPSPoint[]) {
-      if (!points.length) {
-        return []
-      }
-
-      const groupedPoints = []
-      let group: GPSPoint[] = []
-      let prevPoint: GPSPoint = points[0]
-
-      points.forEach((point: GPSPoint, index: number) => {
-        if (
-          index === 0 || (
-            Math.abs(point.latitude - prevPoint.latitude) < this.latlngTolerance &&
-            Math.abs(point.longitude - prevPoint.longitude) < this.latlngTolerance
-          )
-        ) {
-          group.push(point)
-        } else {
-          if (group.length)
-            groupedPoints.push(group[0])
-
-          group = [point]
-        }
-        prevPoint = point
-      })
-
-      if (group.length)
-        groupedPoints.push(group[0])
-
-      return groupedPoints
-    },
-
-    osmLayer() {
-      return new TileLayer({
-        source: new OSM(),
-      })
-    },
-
-    pointsLayer(points: Point[]) {
-      const pointFeatures = points.map((point: Point) => new Feature(point))
-      return new VectorLayer({
-        source: new VectorSource({
-          features: pointFeatures,
-        }),
-        style: 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
-        }),
-      })
-    },
-
-    routeLayer(points: Point[]) {
-      this.routeFeatures = []
-      points.forEach((point: Point, index: number) => {
-        if (index === 0) {
-          return
-        }
-
-        const route = new LineString([points[index - 1].getCoordinates(), point.getCoordinates()])
-        const routeFeature = new Feature(route)
-        this.routeFeatures.push(routeFeature)
-      })
-
-      return new VectorLayer({
-        source: new VectorSource({
-          // @ts-ignore
-          features: this.routeFeatures,
-        }),
-        style: new Style({
-          stroke: new Stroke({
-            color: 'cornflowerblue',
-            width: 2,
-          }),
-        }),
-      })
-    },
-
-    createMap(gpsPoints: GPSPoint[]) {
-      const points = gpsPoints.map((gps: GPSPoint) => {
-        const point = new Point([gps.longitude, gps.latitude])
-        return point
-      });
-
-      const view = new View(this.getCenterAndZoom())
+    createMap(gpsPoints: GPSPoint[]): Map {
+      const points = gpsPoints.map((gps: GPSPoint) => new Point([gps.longitude, gps.latitude]))
+      this.pointsLayer = this.createPointsLayer(points)
+      this.routesLayer = this.createRoutesLayer(points)
+      this.mapView = this.createMapView(gpsPoints)
       const map = new Map({
         target: 'map',
         layers: [
-          this.osmLayer(),
-          this.pointsLayer(points),
-          this.routeLayer(points),
+          this.createMapLayer(),
+          this.pointsLayer,
+          this.routesLayer,
         ],
-        view: view
+        view: this.mapView,
       })
 
-      // @ts-expect-error
+      // @ts-ignore
       this.$refs.popup.bindPopup(map)
       this.bindClick(map)
       this.bindPointerMove(map)
       return map
     },
 
-    getCenterAndZoom() {
-      if (!this.gpsPoints?.length) {
-        return {
-          center: [0, 0],
-          zoom: 2,
-        }
-      }
-
-      let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity]
-      this.gpsPoints.forEach((gps: GPSPoint) => {
-        minX = Math.min(minX, gps.longitude)
-        minY = Math.min(minY, gps.latitude)
-        maxX = Math.max(maxX, gps.longitude)
-        maxY = Math.max(maxY, gps.latitude)
-      })
-
-      const center = [(minX + maxX) / 2, (minY + maxY) / 2]
-      const zoom = Math.max(2, Math.min(18, 18 - Math.log2(Math.max(maxX - minX, maxY - minY))))
-      return { center, zoom }
-    },
-
     bindClick(map: Map) {
       map.on('click', (event) => {
+        this.showControls = false
         const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
+
         if (feature) {
           const point = this.gpsPoints.find((gps: GPSPoint) => {
             const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates()
@@ -200,7 +119,7 @@ export default {
 
           if (point) {
             this.selectedPoint = point
-            // @ts-expect-error
+            // @ts-ignore
             this.$refs.popup.setPosition(event.coordinate)
             // Center the map on the selected point
             map.getView().setCenter(event.coordinate)
@@ -211,38 +130,40 @@ export default {
       })
     },
 
-    bindPointerMove(map: Map) {
-      map.on('pointermove', (event) => {
-        const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
-        const target = map.getTargetElement()
-        if (!target) {
-          return
-        }
+    initQuery() {
+      const urlQuery = this.parseQuery(window.location.href)
+      if (!Object.keys(urlQuery).length) {
+        this.setQuery(this.locationQuery)
+      } else {
+        this.locationQuery = new LocationQuery(urlQuery)
+      }
+    },
+  },
 
-        if (feature) {
-          // @ts-expect-error
-          const coords = feature.getGeometry()?.getCoordinates()
-          if (coords?.length === 2 && coords.every((coord: number) => !isNaN(coord))) {
-            target.title = `${coords[1].toFixed(6)}, ${coords[0].toFixed(6)}`
-          }
-
-          target.style.cursor = 'pointer'
-        } else {
-          target.style.cursor = ''
-          target.title = ''
-        }
-      })
+  watch: {
+    locationQuery: {
+      async handler() {
+        this.setQuery(this.locationQuery)
+        this.gpsPoints = this.groupPoints(await this.fetch())
+        const points = this.gpsPoints.map((gps: GPSPoint) => new Point([gps.longitude, gps.latitude]))
+        this.refreshMapView(this.mapView, this.gpsPoints)
+        this.refreshPointsLayer(this.pointsLayer, points)
+        this.refreshRoutesLayer(this.routesLayer, points)
+      },
+      deep: true,
     },
   },
 
   async mounted() {
-    this.gpsPoints = this.groupPoints(await this.fetchPoints())
+    this.initQuery()
+    this.gpsPoints = this.groupPoints(await this.fetch())
     this.map = this.createMap(this.gpsPoints)
   },
 }
 </script>
 
 <style lang="scss" scoped>
+@use "@/styles/common.scss" as *;
 @import "ol/ol.css";
 
 html,
@@ -256,6 +177,34 @@ body {
   top: 0;
   bottom: 0;
   width: 100%;
+
+  .controls {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    display: flex;
+    flex-direction: column;
+    padding: 0.5em;
+    z-index: 1;
+
+    @include mobile {
+      bottom: 1em;
+    }
+
+    .form-container {
+      margin-bottom: 0.5em;
+      animation: unroll 0.25s ease-out;
+    }
+  }
+}
+
+@keyframes unroll {
+  from {
+    transform: translateY(7.5em);
+  }
+  to {
+    transform: translateY(0);
+  }
 }
 
 :deep(.ol-viewport) {
diff --git a/frontend/src/components/filter/Form.vue b/frontend/src/components/filter/Form.vue
new file mode 100644
index 0000000..0b1d5c3
--- /dev/null
+++ b/frontend/src/components/filter/Form.vue
@@ -0,0 +1,308 @@
+<template>
+  <form class="filter-view" @submit.prevent.stop="handleSubmit">
+    <h2>Filter</h2>
+
+    <div class="date-selectors">
+      <div class="date-selector">
+        <label for="start-date">Start Date</label>
+        <input type="datetime-local"
+               id="start-date"
+               name="start-date"
+               @input="newFilter.startDate = startPlusHours($event.target.value, 0)"
+               @change="newFilter.startDate = startPlusHours($event.target.value, 0)"
+               :value="toLocalString(newFilter.startDate)"
+               :max="maxDate" />
+
+        <div class="footer">
+          <button type="button"
+                  @click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)"
+                  :disabled="!newFilter.startDate">-1w</button>
+          <button type="button"
+                  @click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)"
+                  :disabled="!newFilter.startDate">-1d</button>
+          <button type="button"
+                  @click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)"
+                  :disabled="!newFilter.startDate">-1h</button>
+          <button type="button"
+                  @click="newFilter.startDate = startPlusDays(new Date(), 0)"
+                  :disabled="!newFilter.startDate">Now</button>
+          <button type="button"
+                  @click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)"
+                  :disabled="!newFilter.startDate">+1h</button>
+          <button type="button"
+                  @click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)"
+                  :disabled="!newFilter.startDate">+1d</button>
+          <button type="button"
+                  @click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)"
+                  :disabled="!newFilter.startDate">+1w</button>
+        </div>
+      </div>
+
+      <div class="date-selector">
+        <label for="end-date">End Date</label>
+        <input type="datetime-local"
+               id="end-date"
+               name="end-date"
+               @input="newFilter.endDate = endPlusHours($event.target.value, 0)"
+               @change="newFilter.endDate = endPlusHours($event.target.value, 0)"
+               :value="toLocalString(newFilter.endDate)"
+               :max="maxDate" />
+
+        <div class="footer">
+          <button type="button"
+                  @click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)"
+                  :disabled="!newFilter.endDate">-1w</button>
+          <button type="button"
+                  @click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)"
+                  :disabled="!newFilter.endDate">-1d</button>
+          <button type="button"
+                  @click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)"
+                  :disabled="!newFilter.endDate">-1h</button>
+          <button type="button"
+                  @click="newFilter.endDate = endPlusDays(new Date(), 0)"
+                  :disabled="!newFilter.endDate">Now</button>
+          <button type="button"
+                  @click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)"
+                  :disabled="!newFilter.endDate">+1h</button>
+          <button type="button"
+                  @click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)"
+                  :disabled="!newFilter.endDate">+1d</button>
+          <button type="button"
+                  @click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)"
+                  :disabled="!newFilter.endDate">+1w</button>
+        </div>
+      </div>
+    </div>
+
+    <div class="limit-container input-text-container">
+      <label for="limit">Limit</label>
+      <input type="number"
+             id="limit"
+             name="limit"
+             @input="newFilter.limit = Number($event.target.value)"
+             @change="newFilter.limit = Number($event.target.value)"
+             :value="newFilter.limit"
+             min="1" />
+    </div>
+
+    <div class="footer">
+      <button type="submit" :disabled="!changed">Apply</button>
+    </div>
+  </form>
+</template>
+
+<script lang="ts">
+import _ from 'lodash'
+
+export default {
+  emit: ['refresh'],
+  props: {
+    value: Object,
+  },
+
+  computed: {
+    maxDate() {
+      return this.toLocalString(this.endPlusHours(new Date(), 0))
+    }
+  },
+
+  data() {
+    return {
+      changed: false,
+      newFilter: {...this.value},
+    }
+  },
+
+  methods: {
+    hasChanged(oldValue: any, newValue: any): boolean {
+      return !_.isEqual(
+        {
+          ...oldValue,
+          startDate: this.normalizeDate(this.value.startDate),
+          endDate: this.normalizeDate(this.value.endDate),
+        },
+        {
+          ...newValue,
+          startDate: this.normalizeDate(this.newFilter.startDate),
+          endDate: this.normalizeDate(this.newFilter.endDate),
+        }
+      )
+    },
+
+    normalizeDate(date: Date | number | string | null): Date | null {
+      if (!date) {
+        return null
+      }
+
+      if (typeof date === 'number' || typeof date === 'string') {
+        date = new Date(date)
+      }
+
+      // Round to the nearest minute
+      return new Date(Math.floor(date.getTime() / 60000) * 60000)
+    },
+
+    toLocalString(date: Date | string | number | null): string {
+      const d = this.normalizeDate(date)
+      if (!d) {
+        return ''
+      }
+
+      return new Date(
+        d.getTime() - (d.getTimezoneOffset() * 60000)
+      ).toISOString().slice(0, -8)
+    },
+
+    startPlusHours(date: Date | number | null, hours: number): Date | null {
+      let d = this.normalizeDate(date)
+      if (!d) {
+        return null
+      }
+
+      d = new Date(d.getTime() + hours * 60 * 60 * 1000)
+      const end = this.normalizeDate(this.newFilter.endDate)
+      // Don't accept future dates, or dates that are greater than the current end date
+      if (d.getTime() > new Date().getTime() || end && d.getTime() > end.getTime()) {
+        return end ? new Date(end.getTime() - 60000) : new Date()
+      }
+
+      return d
+    },
+
+    startPlusDays(date: Date | number | null, days: number): Date | null {
+      return this.startPlusHours(date, days * 24)
+    },
+
+    endPlusHours(date: Date | number | null, hours: number): Date | null {
+      let d = this.normalizeDate(date)
+      if (!d) {
+        return null
+      }
+
+      d = new Date(d.getTime() + hours * 60 * 60 * 1000)
+      // Don't accept future dates
+      if (d.getTime() > new Date().getTime()) {
+        return new Date()
+      }
+
+      // Or dates that are less than the current start date
+      const start = this.normalizeDate(this.newFilter.startDate)
+      if (start && d.getTime() < start.getTime()) {
+        return start ? new Date(start.getTime() + 60000) : new Date()
+      }
+
+      return d
+    },
+
+    endPlusDays(date: Date | number | null, days: number): Date | null {
+      return this.endPlusHours(date, days * 24)
+    },
+
+    handleSubmit() {
+      this.$emit('refresh', this.newFilter)
+    },
+  },
+
+  watch: {
+    value: {
+      handler(value) {
+        this.newFilter = {...value}
+        this.changed = false
+      },
+      immediate: true,
+      deep: true,
+    },
+
+    newFilter: {
+      handler(value) {
+        this.changed = this.hasChanged(this.value, value)
+      },
+      immediate: true,
+      deep: true,
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+@use "@/styles/common.scss";
+
+.filter-view {
+  background: var(--color-background);
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 1em;
+  border: 1px solid var(--color-border);
+  border-radius: 0.5em;
+  margin-bottom: 0.25em;
+  box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
+
+  .date-selectors {
+    display: flex;
+    justify-content: space-between;
+    width: 100%;
+
+    @include common.mobile {
+      flex-direction: column;
+    }
+
+    .date-selector {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      margin: 0.5em;
+
+      label {
+        margin-bottom: 0.25em;
+      }
+
+      input {
+        width: 100%;
+      }
+
+      .footer {
+        display: flex;
+        justify-content: center;
+        margin-top: 0.5em;
+
+        button {
+          padding: 0.25em 0.5em;
+          margin-right: 0.25em;
+          border: 1px solid var(--color-border);
+          border-radius: 0.25em;
+          background: var(--color-background);
+          font-size: 0.75em;
+          cursor: pointer;
+
+          &:disabled {
+            opacity: 0.5;
+            cursor: not-allowed;
+          }
+
+          &:hover {
+            color: var(--color-hover);
+          }
+        }
+      }
+    }
+  }
+
+  .input-text-container {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    margin: 0.5em;
+
+    label {
+      margin-bottom: 0.25em;
+    }
+
+    input {
+      width: 100%;
+    }
+  }
+}
+</style>
diff --git a/frontend/src/components/filter/ToggleButton.vue b/frontend/src/components/filter/ToggleButton.vue
new file mode 100644
index 0000000..0233c02
--- /dev/null
+++ b/frontend/src/components/filter/ToggleButton.vue
@@ -0,0 +1,54 @@
+<template>
+  <button :class="{ 'selected': value }"
+          @click="$emit('input')"
+          :title="title"
+          :aria-pressed="value"
+          :aria-label="title">
+    <font-awesome-icon icon="fas fa-filter" />
+  </button>
+</template>
+
+<script lang="ts">
+export default {
+  emits: ['input'],
+  props: {
+    value: Boolean,
+  },
+
+  computed: {
+    title(): string {
+      return this.value ? 'Hide filters' : 'Show filters'
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+button {
+  background: var(--color-background);
+  color: var(--color-text);
+  width: 4em;
+  height: 4em;
+  padding: 1.5em;
+  outline: none;
+  border: none;
+  border-radius: 50%;
+  box-shadow: 1px 1px 2px 2px var(--color-border);
+  cursor: pointer;
+
+  &:hover {
+    color: var(--color-accent) !important;
+  }
+
+  &.selected {
+    background: var(--color-accent);
+    color: var(--color-background);
+    font-weight: bold;
+    box-shadow: inset 1px 1px 2px 2px var(--color-accent);
+
+    &:hover {
+      color: var(--color-background) !important;
+    }
+  }
+}
+</style>
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 5a5dbdb..50731ac 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -4,8 +4,20 @@ import { createApp } from 'vue'
 import App from './App.vue'
 import router from './router'
 
+/* import the fontawesome core */
+import { library } from '@fortawesome/fontawesome-svg-core'
+
+/* import font awesome icon component */
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+
+/* import icon kits */
+import { fas } from '@fortawesome/free-solid-svg-icons'
+import { far } from '@fortawesome/free-regular-svg-icons'
+
+/* add icons to the library */
+library.add(fas, far)
+
 const app = createApp(App)
-
-app.use(router)
-
-app.mount('#app')
+  .component('font-awesome-icon', FontAwesomeIcon)
+  .use(router)
+  .mount('#app')
diff --git a/frontend/src/mixins/Api.vue b/frontend/src/mixins/Api.vue
new file mode 100644
index 0000000..672d67e
--- /dev/null
+++ b/frontend/src/mixins/Api.vue
@@ -0,0 +1,34 @@
+<script lang="ts">
+import GPSPoint from '../models/GPSPoint';
+import LocationQuery from '../models/LocationQuery';
+
+// @ts-ignore
+const baseURL = __API_PATH__
+
+export default {
+  methods: {
+    async fetchPoints(query: LocationQuery): Promise<GPSPoint[]> {
+      const response = await fetch(
+        `${baseURL}/gpsdata?` + new URLSearchParams(
+          Object.entries(query).reduce((acc: any, [key, value]) => {
+            if (value != null && key != 'data') {
+              acc[key] = value
+            }
+
+            if (value instanceof Date) {
+              acc[key] = value.getTime()
+            }
+
+            return acc
+          }, {})
+        )
+      )
+
+      return (await response.json())
+        .map((gps: any) => {
+          return new GPSPoint(gps)
+        })
+    },
+  },
+}
+</script>
diff --git a/frontend/src/mixins/Geo.vue b/frontend/src/mixins/Geo.vue
new file mode 100644
index 0000000..452d22c
--- /dev/null
+++ b/frontend/src/mixins/Geo.vue
@@ -0,0 +1,22 @@
+<script lang="ts">
+import GPSPoint from '../models/GPSPoint'
+
+export default {
+  methods: {
+    latLngToDistance(p: GPSPoint, q: GPSPoint): number {
+      const R = 6371e3 // metres
+      const φ1 = p.latitude * Math.PI / 180 // φ, λ in radians
+      const φ2 = q.latitude * Math.PI / 180
+      const Δφ = (q.latitude - p.latitude) * Math.PI / 180
+      const Δλ = (q.longitude - p.longitude) * 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 // in metres
+    },
+  },
+}
+</script>
diff --git a/frontend/src/mixins/MapView.vue b/frontend/src/mixins/MapView.vue
new file mode 100644
index 0000000..ded1c5d
--- /dev/null
+++ b/frontend/src/mixins/MapView.vue
@@ -0,0 +1,32 @@
+<script lang="ts">
+import OSM from 'ol/source/OSM';
+import View from 'ol/View';
+import TileLayer from 'ol/layer/Tile';
+import { useGeographic } from 'ol/proj';
+
+import GPSPoint from '../models/GPSPoint';
+import Points from './Points.vue';
+
+useGeographic()
+
+export default {
+  mixins: [Points],
+  methods: {
+    createMapLayer(): TileLayer {
+      return new TileLayer({
+        source: new OSM(),
+      })
+    },
+
+    createMapView(points: GPSPoint[]): View {
+      return new View(this.getCenterAndZoom(points))
+    },
+
+    refreshMapView(view: View, points: GPSPoint[]) {
+      const { center, zoom } = this.getCenterAndZoom(points)
+      view.setCenter(center)
+      view.setZoom(zoom)
+    },
+  },
+}
+</script>
diff --git a/frontend/src/mixins/Points.vue b/frontend/src/mixins/Points.vue
new file mode 100644
index 0000000..2bccde4
--- /dev/null
+++ b/frontend/src/mixins/Points.vue
@@ -0,0 +1,125 @@
+<script lang="ts">
+import Feature from 'ol/Feature';
+import Map from 'ol/Map';
+import Point from 'ol/geom/Point';
+import VectorLayer from 'ol/layer/Vector';
+import VectorSource from 'ol/source/Vector';
+import { Circle, Fill, Style, Stroke } from 'ol/style';
+import { useGeographic } from 'ol/proj';
+
+import GPSPoint from '../models/GPSPoint';
+import Geo from './Geo.vue';
+
+const minZoom = 2
+const maxZoom = 18
+
+useGeographic()
+
+export default {
+  mixins: [Geo],
+  data() {
+    return {
+      metersTolerance: 20,
+    }
+  },
+
+  methods: {
+    groupPoints(points: GPSPoint[]) {
+      if (!points.length) {
+        return []
+      }
+
+      const groupedPoints = []
+      let group: GPSPoint[] = []
+      let prevPoint: GPSPoint = points[0]
+
+      points.forEach((point: GPSPoint, index: number) => {
+        if (index === 0 || this.latLngToDistance(point, prevPoint) < this.metersTolerance) {
+          group.push(point)
+        } else {
+          if (group.length)
+            groupedPoints.push(group[0])
+
+          group = [point]
+        }
+        prevPoint = point
+      })
+
+      if (group.length)
+        groupedPoints.push(group[0])
+
+      return groupedPoints
+    },
+
+    createPointsLayer(points: Point[]): VectorLayer {
+      const pointFeatures = points.map((point: Point) => new Feature(point))
+      return new VectorLayer({
+        source: new VectorSource({
+          features: pointFeatures,
+        }),
+        style: 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
+        }),
+      })
+    },
+
+    refreshPointsLayer(layer: VectorLayer, points: Point[]) {
+      const source = layer.getSource()
+      source.clear()
+      source.addFeatures(points.map((point: Point) => new Feature(point)))
+      source.changed()
+    },
+
+    getCenterAndZoom(points: GPSPoint[]) {
+      if (!points?.length) {
+        return {
+          center: [0, 0],
+          zoom: minZoom,
+        }
+      }
+
+      let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity]
+      points.forEach((gps: GPSPoint) => {
+        minX = Math.min(minX, gps.longitude)
+        minY = Math.min(minY, gps.latitude)
+        maxX = Math.max(maxX, gps.longitude)
+        maxY = Math.max(maxY, gps.latitude)
+      })
+
+      const center = [(minX + maxX) / 2, (minY + maxY) / 2]
+      const winScaleMultiplier = (window.innerHeight / window.innerWidth) * 2560
+      const logDisplacement = Math.log2(Math.max(maxX - minX, maxY - minY) * winScaleMultiplier)
+      const zoom = Math.max(minZoom, Math.min(maxZoom, (maxZoom + 2) - logDisplacement))
+      return { center, zoom }
+    },
+
+    bindPointerMove(map: Map) {
+      map.on('pointermove', (event) => {
+        const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
+        const target = map.getTargetElement()
+        if (!target) {
+          return
+        }
+
+        if (feature) {
+          // @ts-expect-error
+          const coords = feature.getGeometry()?.getCoordinates()
+          if (coords?.length === 2 && coords.every((coord: number) => !isNaN(coord))) {
+            target.title = `${coords[1].toFixed(6)}, ${coords[0].toFixed(6)}`
+          }
+
+          target.style.cursor = 'pointer'
+        } else {
+          target.style.cursor = ''
+          target.title = ''
+        }
+      })
+    },
+  },
+}
+</script>
diff --git a/frontend/src/mixins/Routes.vue b/frontend/src/mixins/Routes.vue
new file mode 100644
index 0000000..d7af229
--- /dev/null
+++ b/frontend/src/mixins/Routes.vue
@@ -0,0 +1,53 @@
+<script lang="ts">
+import Feature from 'ol/Feature';
+import LineString from 'ol/geom/LineString';
+import Point from 'ol/geom/Point';
+import VectorLayer from 'ol/layer/Vector';
+import VectorSource from 'ol/source/Vector';
+import { Style, Stroke } from 'ol/style';
+
+export default {
+  methods: {
+    createRoutesLayer(points: Point[]) {
+      const routeFeatures = this.extractRouteFeatures(points)
+      return new VectorLayer({
+        source: new VectorSource({
+          // @ts-ignore
+          features: routeFeatures,
+        }),
+        style: new Style({
+          stroke: new Stroke({
+            color: 'cornflowerblue',
+            width: 3,
+          }),
+        }),
+      })
+    },
+
+    refreshRoutesLayer(layer: VectorLayer, points: Point[]) {
+      const routeFeatures = this.extractRouteFeatures(points)
+      const source = layer.getSource()
+      source.clear()
+      source.addFeatures(routeFeatures)
+      source.changed()
+    },
+
+    extractRouteFeatures(points: Point[]): Feature[] {
+      const routeFeatures = []
+      points.forEach((point: Point, index: number) => {
+        if (index === 0) {
+          return
+        }
+
+        const route = new LineString(
+          [points[index - 1].getCoordinates(), point.getCoordinates()]
+        )
+        const routeFeature = new Feature(route)
+        routeFeatures.push(routeFeature)
+      })
+
+      return routeFeatures
+    }
+  },
+}
+</script>
diff --git a/frontend/src/mixins/URLQueryHandler.vue b/frontend/src/mixins/URLQueryHandler.vue
new file mode 100644
index 0000000..dde2fb6
--- /dev/null
+++ b/frontend/src/mixins/URLQueryHandler.vue
@@ -0,0 +1,90 @@
+<script lang="ts">
+import _ from 'lodash'
+
+function isDate(key: string, value: string): boolean {
+  return (
+    (
+      key.toLowerCase().endsWith('date') ||
+      key.toLowerCase().endsWith('time') ||
+      key.toLowerCase().endsWith('timestamp')
+    ) && new Date(value).toString() !== 'Invalid Date'
+  )
+}
+
+function parseValue(key: string, value: string | null): string | number | boolean | Date {
+  value = decodeURI(value?.trim() || '')
+  if (!value.length) {
+    return undefined
+  } else if (value.toLowerCase() === 'true') {
+    return true
+  } else if (value.toLowerCase() === 'false') {
+    return false
+  } else if (isDate(key, value)) {
+    return new Date(value)
+  } else if (!isNaN(Number(value))) {
+    return Number(value)
+  } else {
+    return value
+  }
+}
+
+function encodeValue(value: string | number | boolean | Date): string {
+  if (value instanceof Date) {
+    return value.getTime().toString()
+  } else {
+    return encodeURI(value.toString())
+  }
+}
+
+export default {
+  data() {
+    return {
+      query: this.parseQuery(window.location.href),
+    }
+  },
+
+  methods: {
+    parseQuery(query: string): Record<string, string> {
+      return query
+        .replace(/^[^#]*#?(.*)/, (_, hash) => hash)
+        .split('&')
+        .reduce((acc: Record<string, any>, pair: string) => {
+          const [key, value] = pair.split('=', 2).map((part: string) => part.trim())
+          if (key.length) {
+            const v = parseValue(key, value)
+            if (v != null) {
+              acc[key] = v
+            }
+          }
+          return acc
+        }, {})
+    },
+
+    isQueryChanged(oldQuery: Record<string, any>, newQuery: Record<string, any>): boolean {
+      return !_.isEqual(oldQuery, newQuery)
+    },
+
+    toQueryString(values: Record<string, any>) {
+      return Object.entries(values)
+        .filter(([_, value]) => value != null && value.toString() !== '[object Object]')
+        .map(([key, value]) => `${key}=${encodeValue(value)}`)
+        .join('&')
+    },
+
+    setQuery(values: Record<string, any>) {
+      const newQuery = this.toQueryString(values)
+      window.location.hash = newQuery
+    },
+  },
+
+  watch: {
+    $route(newRoute, oldRoute) {
+      const oldQuery = this.parseQuery(oldRoute.fullPath)
+      const newQuery = this.parseQuery(newRoute.fullPath)
+      if (this.isQueryChanged(oldQuery, newQuery)) {
+        this.query = newQuery
+      }
+    },
+  },
+}
+</script>
diff --git a/frontend/src/models/LocationQuery.ts b/frontend/src/models/LocationQuery.ts
new file mode 100644
index 0000000..4a320e9
--- /dev/null
+++ b/frontend/src/models/LocationQuery.ts
@@ -0,0 +1,31 @@
+class LocationQuery {
+  public limit: number = 250;
+  public offset: number = 0;
+  public startDate: Date | null = null;
+  public endDate: Date | null = null;
+  public minId: number | null = null;
+  public maxId: number | null = null;
+  public country: string | null = null;
+  public locality: string | null = null;
+  public postalCode: string | null = null;
+
+  constructor(public data: any) {
+    this.limit = data.limit || this.limit;
+    this.offset = data.offset || this.offset;
+    this.startDate = data.startDate || this.startDate;
+    this.endDate = data.endDate || this.endDate;
+    this.minId = data.minId || this.minId;
+    this.maxId = data.maxId || this.maxId;
+    this.country = data.country || this.country;
+    this.locality = data.locality || this.locality;
+    this.postalCode = data.postalCode || this.postalCode;
+
+    if (!(this.startDate && this.endDate)) {
+      // Default to the past 24 hours
+      this.endDate = new Date();
+      this.startDate = new Date(this.endDate.getTime() - 24 * 60 * 60 * 1000);
+    }
+  }
+}
+
+export default LocationQuery;
diff --git a/frontend/src/styles/common.scss b/frontend/src/styles/common.scss
new file mode 100644
index 0000000..71446d3
--- /dev/null
+++ b/frontend/src/styles/common.scss
@@ -0,0 +1,24 @@
+// Set screen width breakpoints
+$screen-xs: 480px;
+$screen-sm: 768px;
+$screen-md: 992px;
+$screen-lg: 1200px;
+
+// @media utilities for common screen sizes
+@mixin mobile {
+  @media (max-width: $screen-sm) {
+    @content;
+  }
+}
+
+@mixin tablet {
+  @media (min-width: $screen-sm) and (max-width: $screen-md) {
+    @content;
+  }
+}
+
+@mixin desktop {
+  @media (min-width: $screen-md) {
+    @content;
+  }
+}
diff --git a/src/helpers/logging.ts b/src/helpers/logging.ts
index 12ea595..e6f4b47 100644
--- a/src/helpers/logging.ts
+++ b/src/helpers/logging.ts
@@ -1,3 +1,3 @@
 export function logRequest(req: any) {
-  console.log(`Request: ${req.method} ${req.url}`);
+  console.log(`[${req.ip}] ${req.method} ${req.url}`);
 }