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}`); }