From 78fbf45bd614e1b3a3da8f4ed64e785fff0a45a7 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Mon, 24 Feb 2025 00:28:59 +0100 Subject: [PATCH] Support for paginated results. --- frontend/src/components/Map.vue | 99 +++++++++++++++++++++---- frontend/src/components/filter/Form.vue | 61 ++++++++++----- frontend/src/mixins/Api.vue | 7 +- frontend/src/mixins/Paginate.vue | 53 +++++++++++++ frontend/src/mixins/Points.vue | 6 ++ frontend/src/models/GPSPoint.ts | 2 + frontend/src/models/LocationQuery.ts | 4 +- src/models/LocationRequest.ts | 5 +- 8 files changed, 200 insertions(+), 37 deletions(-) create mode 100644 frontend/src/mixins/Paginate.vue diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue index 4822a7b..851409c 100644 --- a/frontend/src/components/Map.vue +++ b/frontend/src/components/Map.vue @@ -9,7 +9,14 @@ <div class="controls"> <div class="form-container" v-if="showControls"> - <FilterForm :value="locationQuery" @refresh="locationQuery = $event" /> + <FilterForm :value="locationQuery" + :disabled="loading" + :has-next-page="hasNextPage" + :has-prev-page="hasPrevPage" + @refresh="locationQuery = $event" + @reset-page="locationQuery.minId = locationQuery.maxId = undefined" + @next-page="fetchNextPage" + @prev-page="fetchPrevPage" /> </div> <FilterButton @input="showControls = !showControls" :value="showControls" /> @@ -35,6 +42,7 @@ import FilterForm from './filter/Form.vue'; import GPSPoint from '../models/GPSPoint'; import LocationQuery from '../models/LocationQuery'; 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 URLQueryHandler from '../mixins/URLQueryHandler.vue'; @@ -45,6 +53,7 @@ export default { mixins: [ Api, MapView, + Paginate, Points, Routes, URLQueryHandler, @@ -58,13 +67,14 @@ export default { data() { return { - gpsPoints: [] as GPSPoint[], loading: false, locationQuery: new LocationQuery({}), map: null as Nullable<Map>, + mappedPoints: [] as Point[], mapView: null as Nullable<View>, pointsLayer: null as Nullable<VectorLayer>, popup: null as Nullable<Overlay>, + queryInitialized: false, routesLayer: null as Nullable<VectorLayer>, selectedPoint: null as Nullable<GPSPoint>, showControls: false, @@ -84,11 +94,29 @@ export default { } }, + fetchNextPage() { + const nextPageQuery = this.nextPageQuery() + if (!nextPageQuery) { + return + } + + this.locationQuery = nextPageQuery + }, + + fetchPrevPage() { + const prevPageQuery = this.prevPageQuery() + if (!prevPageQuery) { + return + } + + this.locationQuery = prevPageQuery + }, + 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) + this.mappedPoints = this.toMappedPoints(gpsPoints) + this.pointsLayer = this.createPointsLayer(this.mappedPoints) + this.routesLayer = this.createRoutesLayer(this.mappedPoints) + this.mapView = this.mapView || this.createMapView(gpsPoints) const map = new Map({ target: 'map', layers: [ @@ -142,13 +170,56 @@ export default { 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) + async handler(newQuery, oldQuery) { + const isFirstQuery = !this.queryInitialized + + // If startDate/endDate have changed, reset minId/maxId + if (!isFirstQuery && + (newQuery.startDate !== oldQuery.startDate || newQuery.endDate !== oldQuery.endDate) + ) { + newQuery.minId = undefined + newQuery.maxId = undefined + this.hasNextPage = true + this.hasPrevPage = true + } + + // Results with maxId should be retrieved in descending order, + // otherwise all results should be retrieved in ascending order + newQuery.order = newQuery.maxId ? 'desc' : 'asc' + this.setQuery(newQuery) + this.queryInitialized = true + + if (!isFirstQuery) { + const gpsPoints = await this.fetch() + + // If there are no points, and minId/maxId are set, reset them + // and don't update the map (it means that we have reached the + // start/end of the current window) + if (gpsPoints.length < 2 && (newQuery.minId || newQuery.maxId)) { + if (newQuery.minId) { + this.hasNextPage = false + } + + if (newQuery.maxId) { + this.hasPrevPage = false + } + + newQuery.minId = oldQuery.minId + newQuery.maxId = oldQuery.maxId + return + } + + this.gpsPoints = gpsPoints + this.hasNextPage = gpsPoints.length > 1 + this.hasPrevPage = gpsPoints.length > 1 + } + + this.mappedPoints = this.toMappedPoints(this.gpsPoints) + if (this.mapView) { + this.refreshMapView(this.mapView, this.gpsPoints) + this.refreshPointsLayer(this.pointsLayer, this.mappedPoints) + this.refreshRoutesLayer(this.routesLayer, this.mappedPoints) + } }, deep: true, }, @@ -156,7 +227,7 @@ export default { async mounted() { this.initQuery() - this.gpsPoints = this.groupPoints(await this.fetch()) + this.gpsPoints = await this.fetch() this.map = this.createMap(this.gpsPoints) }, } diff --git a/frontend/src/components/filter/Form.vue b/frontend/src/components/filter/Form.vue index d8989ab..105cbe1 100644 --- a/frontend/src/components/filter/Form.vue +++ b/frontend/src/components/filter/Form.vue @@ -11,30 +11,31 @@ @input="newFilter.startDate = startPlusHours($event.target.value, 0)" @change="newFilter.startDate = startPlusHours($event.target.value, 0)" :value="toLocalString(newFilter.startDate)" + :disabled="disabled" :max="maxDate" /> <div class="footer"> <button type="button" @click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)" - :disabled="!newFilter.startDate">-1w</button> + :disabled="disabled || !newFilter.startDate">-1w</button> <button type="button" @click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)" - :disabled="!newFilter.startDate">-1d</button> + :disabled="disabled || !newFilter.startDate">-1d</button> <button type="button" @click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)" - :disabled="!newFilter.startDate">-1h</button> + :disabled="disabled || !newFilter.startDate">-1h</button> <button type="button" @click="newFilter.startDate = startPlusDays(new Date(), 0)" - :disabled="!newFilter.startDate">Now</button> + :disabled="disabled || !newFilter.startDate">Now</button> <button type="button" @click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)" - :disabled="!newFilter.startDate">+1h</button> + :disabled="disabled || !newFilter.startDate">+1h</button> <button type="button" @click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)" - :disabled="!newFilter.startDate">+1d</button> + :disabled="disabled || !newFilter.startDate">+1d</button> <button type="button" @click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)" - :disabled="!newFilter.startDate">+1w</button> + :disabled="disabled || !newFilter.startDate">+1w</button> </div> </div> @@ -46,40 +47,50 @@ @input="newFilter.endDate = endPlusHours($event.target.value, 0)" @change="newFilter.endDate = endPlusHours($event.target.value, 0)" :value="toLocalString(newFilter.endDate)" + :disabled="disabled" :max="maxDate" /> <div class="footer"> <button type="button" @click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)" - :disabled="!newFilter.endDate">-1w</button> + :disabled="disabled || !newFilter.endDate">-1w</button> <button type="button" @click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)" - :disabled="!newFilter.endDate">-1d</button> + :disabled="disabled || !newFilter.endDate">-1d</button> <button type="button" @click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)" - :disabled="!newFilter.endDate">-1h</button> + :disabled="disabled || !newFilter.endDate">-1h</button> <button type="button" @click="newFilter.endDate = endPlusDays(new Date(), 0)" - :disabled="!newFilter.endDate">Now</button> + :disabled="disabled || !newFilter.endDate">Now</button> <button type="button" @click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)" - :disabled="!newFilter.endDate">+1h</button> + :disabled="disabled || !newFilter.endDate">+1h</button> <button type="button" @click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)" - :disabled="!newFilter.endDate">+1d</button> + :disabled="disabled || !newFilter.endDate">+1d</button> <button type="button" @click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)" - :disabled="!newFilter.endDate">+1w</button> + :disabled="disabled || !newFilter.endDate">+1w</button> </div> </div> </div> <div class="pagination-container"> + <div class="page-button-container"> + <button type="button" + :disabled="disabled" + v-if="value.minId || value.maxId" + @click.stop="$emit('reset-page')"> + <font-awesome-icon icon="fas fa-undo" /> + </button> + </div> + <div class="page-button-container"> <button type="button" @click="$emit('prev-page')" title="Previous Results" - :disabled="!hasPrev"> + :disabled="disabled || !hasPrevPage"> <font-awesome-icon icon="fas fa-chevron-left" /> </button> </div> @@ -91,6 +102,7 @@ @input="newFilter.limit = Number($event.target.value)" @change="newFilter.limit = Number($event.target.value)" :value="newFilter.limit" + :disabled="disabled" min="1" /> </div> @@ -98,14 +110,16 @@ <button type="button" @click="$emit('next-page')" title="Next Results" - :disabled="!hasNext"> + :disabled="disabled || !hasNextPage"> <font-awesome-icon icon="fas fa-chevron-right" /> </button> </div> </div> <div class="footer"> - <button type="submit" :disabled="!changed">Apply</button> + <button type="submit" :disabled="disabled || !changed"> + <font-awesome-icon icon="fas fa-check" /> Apply + </button> </div> </form> </template> @@ -117,15 +131,20 @@ export default { emit: [ 'next-page', 'prev-page', + 'reset-page', 'refresh', ], props: { value: Object, - hasPrev: { + disabled: { + type: Boolean, + default: false, + }, + hasPrevPage: { type: Boolean, default: true, }, - hasNext: { + hasNextPage: { type: Boolean, default: true, }, @@ -333,5 +352,9 @@ export default { width: 100%; } } + + button[type=submit] { + min-width: 10em; + } } </style> diff --git a/frontend/src/mixins/Api.vue b/frontend/src/mixins/Api.vue index 672d67e..6dd3466 100644 --- a/frontend/src/mixins/Api.vue +++ b/frontend/src/mixins/Api.vue @@ -26,8 +26,13 @@ export default { return (await response.json()) .map((gps: any) => { - return new GPSPoint(gps) + return new GPSPoint({ + ...gps, + // Normalize timestamp to Date object + timestamp: new Date(gps.timestamp), + }) }) + .sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime()) }, }, } diff --git a/frontend/src/mixins/Paginate.vue b/frontend/src/mixins/Paginate.vue new file mode 100644 index 0000000..f1254f7 --- /dev/null +++ b/frontend/src/mixins/Paginate.vue @@ -0,0 +1,53 @@ +<script lang="ts"> +import GPSPoint from '../models/GPSPoint'; +import LocationQuery from '../models/LocationQuery'; + +export default { + data() { + return { + gpsPoints: [] as GPSPoint[], + hasNextPage: true, + hasPrevPage: true, + } + }, + + computed: { + newestPoint(): GPSPoint | undefined { + return this.gpsPoints[this.gpsPoints.length - 1] || undefined + }, + + oldestPoint(): GPSPoint | undefined { + return this.gpsPoints[0] || undefined + }, + }, + + methods: { + prevPageQuery(): LocationQuery | null { + if (!this.oldestPoint) { + return null + } + + return new LocationQuery({ + ...this.locationQuery, + minId: undefined, + maxId: this.oldestPoint.id, + // Previous page results should be retrieved in descending order + order: 'desc', + }) + }, + + nextPageQuery(): LocationQuery | null { + if (!this.newestPoint) { + return null + } + + return new LocationQuery({ + ...this.locationQuery, + minId: this.newestPoint.id, + maxId: undefined, + order: 'asc', + }) + }, + }, +} +</script> diff --git a/frontend/src/mixins/Points.vue b/frontend/src/mixins/Points.vue index 2bccde4..2879adb 100644 --- a/frontend/src/mixins/Points.vue +++ b/frontend/src/mixins/Points.vue @@ -75,6 +75,12 @@ export default { source.changed() }, + toMappedPoints(gpsPoints: GPSPoint[]): Point[] { + return this.groupPoints(gpsPoints).map( + (gps: GPSPoint) => new Point([gps.longitude, gps.latitude]) + ) + }, + getCenterAndZoom(points: GPSPoint[]) { if (!points?.length) { return { diff --git a/frontend/src/models/GPSPoint.ts b/frontend/src/models/GPSPoint.ts index 5b65157..67e28db 100644 --- a/frontend/src/models/GPSPoint.ts +++ b/frontend/src/models/GPSPoint.ts @@ -1,4 +1,5 @@ class GPSPoint { + public id: number; public latitude: number; public longitude: number; public altitude: number; @@ -9,6 +10,7 @@ class GPSPoint { public timestamp: Date; constructor(public data: any) { + this.id = data.id; this.latitude = data.latitude; this.longitude = data.longitude; this.altitude = data.altitude; diff --git a/frontend/src/models/LocationQuery.ts b/frontend/src/models/LocationQuery.ts index 4a320e9..484098c 100644 --- a/frontend/src/models/LocationQuery.ts +++ b/frontend/src/models/LocationQuery.ts @@ -1,6 +1,6 @@ class LocationQuery { public limit: number = 250; - public offset: number = 0; + public offset: number | null = null; public startDate: Date | null = null; public endDate: Date | null = null; public minId: number | null = null; @@ -8,6 +8,7 @@ class LocationQuery { public country: string | null = null; public locality: string | null = null; public postalCode: string | null = null; + public order: string = 'asc'; constructor(public data: any) { this.limit = data.limit || this.limit; @@ -19,6 +20,7 @@ class LocationQuery { this.country = data.country || this.country; this.locality = data.locality || this.locality; this.postalCode = data.postalCode || this.postalCode; + this.order = data.order || this.order; if (!(this.startDate && this.endDate)) { // Default to the past 24 hours diff --git a/src/models/LocationRequest.ts b/src/models/LocationRequest.ts index 64d4f73..ece8148 100644 --- a/src/models/LocationRequest.ts +++ b/src/models/LocationRequest.ts @@ -25,7 +25,8 @@ class LocationRequest { this.country = req.country; this.locality = req.locality; this.postalCode = req.postalCode; - this.orderBy = req.orderBy || 'timestamp'; + this.orderBy = req.orderBy || this.orderBy; + this.order = req.order || this.order; } private initNumber(key: string, req: any): void { @@ -93,7 +94,7 @@ class LocationRequest { } queryMap.where = where; - queryMap.order = [[colMapping[this.orderBy], this.order]]; + queryMap.order = [[colMapping[this.orderBy], this.order.toUpperCase()]]; return queryMap; } }