diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue index 38e6a94..48637cb 100644 --- a/frontend/src/components/Map.vue +++ b/frontend/src/components/Map.vue @@ -2,6 +2,11 @@ <main> <div class="loading" v-if="loading">Loading...</div> <div class="map-body" v-else> + <MapSelectOverlay + @close="showSelectOverlay = false" + @select="onAreaSelect" + v-if="showSelectOverlay" /> + <div id="map"> <div class="time-range" v-if="oldestPoint && newestPoint"> <div class="row"> @@ -51,6 +56,16 @@ <FilterButton @input="showControls = !showControls" :value="showControls" /> </div> + + <div class="floating-buttons-container"> + <div class="floating-buttons"> + <FloatingButton + :icon="'fas ' + (hasSelectionBox ? 'fa-remove' : 'fa-object-ungroup')" + :title="showSelectOverlay || hasSelectionBox ? 'Reset selection' : 'Filter by area'" + :primary="showSelectOverlay || hasSelectionBox" + @click="onSelectOverlayButtonClick" /> + </div> + </div> </div> <div class="timeline"> @@ -94,8 +109,11 @@ import Dates from '../mixins/Dates.vue'; import Feature from 'ol/Feature'; import FilterButton from './filter/ToggleButton.vue'; import FilterForm from './filter/Form.vue'; +import FloatingButton from '../elements/FloatingButton.vue'; import GPSPoint from '../models/GPSPoint'; import LocationQuery from '../models/LocationQuery'; +import LocationQueryMixin from '../mixins/LocationQuery.vue'; +import MapSelectOverlay from './MapSelectOverlay.vue'; import MapView from '../mixins/MapView.vue'; import Paginate from '../mixins/Paginate.vue'; import Points from '../mixins/Points.vue'; @@ -111,6 +129,7 @@ export default { mixins: [ Api, Dates, + LocationQueryMixin, MapView, Paginate, Points, @@ -122,6 +141,8 @@ export default { ConfirmDialog, FilterButton, FilterForm, + FloatingButton, + MapSelectOverlay, PointInfo, Timeline, }, @@ -135,7 +156,6 @@ export default { pointToRemove: null as Optional<GPSPoint>, pointsLayer: null as Optional<VectorLayer>, popup: null as Optional<Overlay>, - queryInitialized: false, refreshPoints: 0, routesLayer: null as Optional<VectorLayer>, selectedFeature: null as Optional<Feature>, @@ -143,6 +163,7 @@ export default { selectedPointIndex: null as Optional<number>, showControls: false, showMetrics: new TimelineMetricsConfiguration(), + showSelectOverlay: false, } }, @@ -160,6 +181,13 @@ export default { return this.groupPoints(this.gpsPoints) }, + hasSelectionBox(): boolean { + return this.locationQuery.minLongitude != null && + this.locationQuery.minLatitude != null && + this.locationQuery.maxLongitude != null && + this.locationQuery.maxLatitude != null + }, + mappedPoints(): Record<string, Point> { // Reference refreshPoints to force reactivity this.refreshPoints; @@ -378,26 +406,72 @@ export default { setShowMetrics(metrics: any) { Object.assign(this.showMetrics, metrics) }, + + onAreaSelect(selectionBox: number[][]) { + this.showSelectOverlay = false + if (!selectionBox.length) { + return + } + + let [start, end] = selectionBox + if (!(start && end)) { + return + } + + start = this.map.getCoordinateFromPixel(start) + end = this.map.getCoordinateFromPixel(end) + const [startLon, startLat, endLon, endLat] = [ + Math.min(start[0], end[0]), + Math.min(start[1], end[1]), + Math.max(start[0], end[0]), + Math.max(start[1], end[1]), + ] + + this.locationQuery = { + ...this.locationQuery, + startDate: null, + endDate: null, + minLongitude: startLon, + minLatitude: startLat, + maxLongitude: endLon, + maxLatitude: endLat, + } + }, + + onSelectOverlayButtonClick() { + if (!this.hasSelectionBox) { + this.showSelectOverlay = true + } else { + this.locationQuery = { + ...this.locationQuery, + minLongitude: null, + minLatitude: null, + maxLongitude: null, + maxLatitude: null, + } + } + }, }, watch: { locationQuery: { - async handler(newQuery, oldQuery) { - const isFirstQuery = !this.queryInitialized + async handler(newQuery: LocationQuery, oldQuery: LocationQuery) { + if (!this.isQueryChanged({ + newValue: newQuery, + oldValue: oldQuery, + })) { + return + } // If startDate/endDate have changed, reset minId/maxId - if (!isFirstQuery && - (newQuery.startDate !== oldQuery.startDate || newQuery.endDate !== oldQuery.endDate) - ) { + if (newQuery.startDate !== oldQuery.startDate || newQuery.endDate !== oldQuery.endDate) { newQuery.minId = null newQuery.maxId = null 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' + newQuery.order = 'desc' this.setQuery( { ...newQuery, @@ -406,33 +480,29 @@ export default { } ) - this.queryInitialized = true + const gpsPoints = await this.fetch() - 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 + // 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 } - this.gpsPoints = gpsPoints - this.hasNextPage = gpsPoints.length > 1 - this.hasPrevPage = gpsPoints.length > 1 + 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 + if (this.mapView) { this.refreshMap() } @@ -474,10 +544,9 @@ export default { <style lang="scss" scoped> @use "@/styles/common.scss" as *; +@use "./vars.scss" as *; @import "ol/ol.css"; -$timeline-height: 10rem; - main { width: 100%; height: 100%; @@ -556,6 +625,25 @@ main { box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.5); } +.floating-buttons-container { + width: 100%; + height: 5em; + position: absolute; + bottom: 0; + right: 0; + + .floating-buttons { + height: 100%; + position: relative; + display: flex; + justify-content: flex-end; + + :deep(button) { + position: absolute; + } + } +} + :deep(.ol-viewport) { .ol-attribution { display: none; diff --git a/frontend/src/components/MapSelectOverlay.vue b/frontend/src/components/MapSelectOverlay.vue new file mode 100644 index 0000000..a6fb20d --- /dev/null +++ b/frontend/src/components/MapSelectOverlay.vue @@ -0,0 +1,192 @@ +<template> + <div class="overlay-container"> + <div class="overlay-popup" @click.stop> + <p>Filter location points in the selected area.</p> + <button @click="$emit('close')">Close</button> + </div> + <div class="overlay" + ref="overlay" + @mousedown="onOverlayDragStart" + @mouseup="onOverlayDragEnd" + @mousemove="onOverlayMove" + @touchstart="onOverlayDragStart" + @touchend="onOverlayDragEnd" + @touchmove="onOverlayMove" + @click="onOverlayDragEnd"> + <div class="box" + :style="selectionBoxStyle" + v-if="selectionBox.length > 1" /> + </div> + </div> +</template> + +<script lang="ts"> +export default { + emits: ['close', 'select'], + + data() { + return { + overlayDragging: false, + selectionBox: [] as number[][], + } + }, + + computed: { + selectionBoxStyle(): Record<string, string> { + if (this.selectionBox.length < 2) { + return {} + } + + const scaledCoords = [ + this.scaledPointerCoordinates(...this.selectionBox[0]), + this.scaledPointerCoordinates(...this.selectionBox[1]), + ] + + const [minX, minY, maxX, maxY] = this.sorted(scaledCoords).flat() + return { + top: minY + 'px', + left: minX + 'px', + width: `${maxX - minX}px`, + height: `${maxY - minY}px`, + } + }, + + hasDistinctPoints(): boolean { + return this.selectionBox.length > 1 && this.selectionBox[1] && ( + this.selectionBox[0][0] !== this.selectionBox[1][0] || this.selectionBox[0][1] !== this.selectionBox[1][1] + ) + }, + }, + + methods: { + sorted(coords: number[][]): number[][] { + if ((coords?.length || 0) < 2) { + return coords + } + + return [ + [Math.min(coords[0][0], coords[1][0]), Math.min(coords[0][1], coords[1][1])], + [Math.max(coords[0][0], coords[1][0]), Math.max(coords[0][1], coords[1][1])], + ] + }, + + scaledPointerCoordinates(x: number, y: number): number[] { + const offsetLeft = this.$refs.overlay?.getBoundingClientRect().left || 0 + const offsetTop = this.$refs.overlay?.getBoundingClientRect().top || 0 + + return [ + x - offsetLeft, + y - offsetTop, + ] + }, + + setSelectionBoxCoordinates(event: MouseEvent) { + const coords = [event.clientX, event.clientY] + let newBox = JSON.parse(JSON.stringify(this.selectionBox)) as number[][] + + if (newBox.length === 1 || !newBox[1]) { + newBox.push(coords) + } else { + newBox[1] = coords + newBox = newBox.sort((a, b) => a[0] - b[0]) + } + + this.selectionBox = newBox + }, + + onOverlayDragStart(event: MouseEvent) { + this.selectionBox = [] + this.setSelectionBoxCoordinates(event) + this.overlayDragging = true + }, + + onOverlayDragEnd(event: MouseEvent) { + if (this.selectionBox.length < 1) { + this.selectionBox = [] + return + } + + if (!this.hasDistinctPoints) { + this.selectionBox = [] + } + + this.setSelectionBoxCoordinates(event) + this.overlayDragging = false + + if (this.hasDistinctPoints) { + this.$emit( + 'select', + [ + this.scaledPointerCoordinates(...this.selectionBox[0]), + this.scaledPointerCoordinates(...this.selectionBox[1]) + ] + ) + } + }, + + onOverlayMove(event: MouseEvent) { + if (!this.overlayDragging || this.selectionBox.length < 1) { + return + } + + this.setSelectionBoxCoordinates(event) + }, + + } +} +</script> + +<style lang="scss" scoped> +@use "./vars.scss" as *; + +.overlay-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: calc(100% - #{$timeline-height}); + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + + .overlay-popup { + position: absolute; + bottom: 0; + background: rgba(0, 0, 0, 0.15); + color: white; + display: flex; + flex-direction: column; + padding: 1em; + border-radius: 0.25em; + box-shadow: 0 0 0.25em rgba(0, 0, 0, 0.5); + opacity: 0.75; + z-index: 1002; + + button { + background: transparent; + color: white; + margin-top: 0.5em; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + } + + .overlay { + position: absolute; + width: 100%; + height: 100%; + z-index: 1001; + + .box { + position: absolute; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--color-accent); + } + } +} +</style> diff --git a/frontend/src/components/Timeline.vue b/frontend/src/components/Timeline.vue index caf2719..cf568d3 100644 --- a/frontend/src/components/Timeline.vue +++ b/frontend/src/components/Timeline.vue @@ -210,6 +210,12 @@ export default { } } + const xTicks = {} as { min?: Date, max?: Date } + if (this.points.length > 1) { + xTicks.min = this.points[0].timestamp + xTicks.max = this.points[this.points.length - 1].timestamp + } + return { responsive: true, maintainAspectRatio: false, @@ -245,9 +251,9 @@ export default { drawTicks: true, }, time: { - tooltipFormat: 'MMM dd, HH:mm', - unit: 'minute', + tooltipFormat: 'MMM dd yyyy, HH:mm', }, + ticks: xTicks, title: { display: true, text: 'Date' diff --git a/frontend/src/components/filter/Form.vue b/frontend/src/components/filter/Form.vue index 0fc884e..671805c 100644 --- a/frontend/src/components/filter/Form.vue +++ b/frontend/src/components/filter/Form.vue @@ -2,7 +2,16 @@ <form class="filter-view" @submit.prevent.stop="handleSubmit"> <h2>Filter</h2> - <div class="date-selectors"> + <div class="date-range-toggle"> + <input type="checkbox" + id="date-range-toggle" + name="date-range-toggle" + v-model="enableDateRange" + :disabled="disabled" /> + <label for="date-range-toggle">Enable Date Range</label> + </div> + + <div class="date-selectors" v-if="enableDateRange"> <div class="date-selector"> <label for="start-date">Start Date</label> <input type="datetime-local" @@ -160,9 +169,12 @@ <script lang="ts"> import _ from 'lodash' +import LocationQuery from '../../models/LocationQuery' +import LocationQueryMixin from '../../mixins/LocationQuery.vue' import UserDevice from '../../models/UserDevice' export default { + mixins: [LocationQueryMixin], emit: [ 'next-page', 'prev-page', @@ -172,7 +184,7 @@ export default { ], props: { - value: Object, + value: LocationQuery, devices: { type: Array as () => UserDevice[], default: () => [], @@ -204,51 +216,13 @@ export default { data() { return { changed: false, + enableDateRange: false, newFilter: {...this.value}, newResolution: this.resolution, } }, 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: any): 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 | Event | undefined | null, hours: number): Date | null { if ((date as any)?.target?.value) { date = (date as any).target.value @@ -316,11 +290,19 @@ export default { setResolution(event: Event) { this.newResolution = Number((event.target as HTMLInputElement).value) }, + + initDateRange(value: LocationQuery) { + this.enableDateRange = !!(value.startDate && value.endDate) + }, + }, + + mounted() { + this.initDateRange(this.value) }, watch: { value: { - handler(value) { + handler(value: LocationQuery) { this.newFilter = {...value} this.changed = false }, @@ -329,16 +311,35 @@ export default { }, newFilter: { - handler(value) { - this.changed = this.hasChanged(this.value, value) + handler(value: LocationQuery) { + this.changed = this.isQueryChanged({ + newValue: value, + oldValue: this.value + }) + this.initDateRange(value) }, immediate: true, deep: true, }, - newResolution(value) { + newResolution(value: number) { this.changed = this.changed || value !== this.resolution }, + + enableDateRange(value: boolean) { + if (!value) { + this.newFilter.startDate = null + this.newFilter.endDate = null + } else { + if (!this.newFilter.startDate) { + this.newFilter.startDate = new Date(new Date().getTime() - 24 * 60 * 60 * 1000) + } + + if (!this.newFilter.endDate) { + this.newFilter.endDate = new Date() + } + } + }, }, } </script> @@ -408,6 +409,16 @@ export default { } } + .date-range-toggle { + display: flex; + align-items: center; + margin: 0.5em 0 -0.5em 0; + + input { + margin-right: 0.25em; + } + } + .pagination-container { display: flex; flex-direction: row; diff --git a/frontend/src/components/vars.scss b/frontend/src/components/vars.scss new file mode 100644 index 0000000..af5a733 --- /dev/null +++ b/frontend/src/components/vars.scss @@ -0,0 +1 @@ +$timeline-height: 10rem; diff --git a/frontend/src/elements/FloatingButton.vue b/frontend/src/elements/FloatingButton.vue index e85491d..dc79495 100644 --- a/frontend/src/elements/FloatingButton.vue +++ b/frontend/src/elements/FloatingButton.vue @@ -1,7 +1,9 @@ <template> <button class="floating-button" + :class="{ primary }" @click="$emit('click')" :title="title" + :style="style" :aria-label="title"> <font-awesome-icon :icon="icon" /> </button> @@ -16,6 +18,16 @@ export default { required: true, }, + primary: { + type: Boolean, + default: false, + }, + + style: { + type: Object, + default: () => ({}), + }, + title: { type: String, }, @@ -28,8 +40,6 @@ button.floating-button { position: fixed; bottom: 1em; right: 1em; - background: var(--color-accent); - color: var(--color-background); width: 4em; height: 4em; font-size: 1em; @@ -42,10 +52,20 @@ button.floating-button { z-index: 100; &:hover { - background: var(--color-accent) !important; - color: var(--color-background) !important; font-weight: bold; filter: brightness(1.2); } + + &.primary { + background: var(--color-accent); + color: var(--color-background); + + &:hover { + background: var(--color-accent) !important; + color: var(--color-background) !important; + font-weight: bold; + filter: brightness(1.2); + } + } } </style> diff --git a/frontend/src/mixins/Dates.vue b/frontend/src/mixins/Dates.vue index 60bae27..e23eaa4 100644 --- a/frontend/src/mixins/Dates.vue +++ b/frontend/src/mixins/Dates.vue @@ -8,6 +8,30 @@ export default { return new Date(date).toString().replace(/GMT.*/, '') }, + + normalizeDate(date: any): 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) + }, } } </script> diff --git a/frontend/src/mixins/LocationQuery.vue b/frontend/src/mixins/LocationQuery.vue new file mode 100644 index 0000000..541212d --- /dev/null +++ b/frontend/src/mixins/LocationQuery.vue @@ -0,0 +1,32 @@ +<script lang="ts"> +import _ from 'lodash' + +import Dates from './Dates.vue' +import LocationQuery from '../models/LocationQuery' + +export default { + mixins: [Dates], + methods: { + isQueryChanged({ + newValue, + oldValue, + }: { + newValue?: LocationQuery, + oldValue?: LocationQuery, + }): boolean { + return !_.isEqual( + { + ...(oldValue || {}), + startDate: this.normalizeDate(oldValue?.startDate), + endDate: this.normalizeDate(oldValue?.endDate), + }, + { + ...(newValue || {}), + startDate: this.normalizeDate(newValue?.startDate), + endDate: this.normalizeDate(newValue?.endDate), + } + ) + }, + } +} +</script> diff --git a/frontend/src/mixins/Paginate.vue b/frontend/src/mixins/Paginate.vue index 1cc62b7..16f7aa4 100644 --- a/frontend/src/mixins/Paginate.vue +++ b/frontend/src/mixins/Paginate.vue @@ -8,7 +8,7 @@ export default { gpsPoints: [] as GPSPoint[], hasNextPage: true, hasPrevPage: true, - locationQuery: new LocationQuery({}), + locationQuery: new LocationQuery({}) as LocationQuery, } }, diff --git a/frontend/src/mixins/SelectionBox.vue b/frontend/src/mixins/SelectionBox.vue new file mode 100644 index 0000000..104431d --- /dev/null +++ b/frontend/src/mixins/SelectionBox.vue @@ -0,0 +1,86 @@ +<script lang="ts"> +export default { + data() { + return { + selectionBox: [] as number[][], + } + }, + + computed: { + selectionBoxStyle(): Record<string, string> { + if (this.selectionBox.length < 2) { + return {} + } + + const [minX, minY, maxX, maxY] = [ + Math.min(this.selectionBox[0][0], this.selectionBox[1][0]), + Math.min(this.selectionBox[0][1], this.selectionBox[1][1]), + Math.max(this.selectionBox[0][0], this.selectionBox[1][0]), + Math.max(this.selectionBox[0][1], this.selectionBox[1][1]), + ] + + return { + top: minY + 'px', + left: minX + 'px', + width: `${maxX - minX}px`, + height: `${maxY - minY}px`, + } + }, + }, + + methods: { + scaledPointerCoordinates(event: MouseEvent): number[] { + const offsetLeft = this.$refs.overlay?.getBoundingClientRect().left || 0 + const offsetTop = this.$refs.overlay?.getBoundingClientRect().top || 0 + + return [ + event.clientX - offsetLeft, + event.clientY - offsetTop, + ] + }, + + setSelectionBoxCoordinates(event: MouseEvent) { + const coords = this.scaledPointerCoordinates(event) + let newBox = JSON.parse(JSON.stringify(this.selectionBox)) as number[][] + + if (newBox.length === 1 || !newBox[1]) { + newBox.push(coords) + } else { + newBox[1] = coords + } + + newBox = newBox.sort((a: number[], b: number[]) => a[0] - b[0]) + this.selectionBox = newBox + }, + + onOverlayDragStart(event: MouseEvent) { + this.setSelectionBoxCoordinates(event) + this.overlayDragging = true + }, + + onOverlayDragEnd(event: MouseEvent) { + if (this.selectionBox.length < 1) { + this.selectionBox = [] + return + } + + this.setSelectionBoxCoordinates(event) + if (this.selectionBox.length > 1 && ( + this.selectionBox[0][0] === this.selectionBox[1][0] && this.selectionBox[0][1] === this.selectionBox[1][1]) + ) { + this.selectionBox = [] + } + + this.overlayDragging = false + }, + + onOverlayMove(event: MouseEvent) { + if (!this.overlayDragging || this.selectionBox.length < 1) { + return + } + + this.setSelectionBoxCoordinates(event) + }, + }, +} +</script> diff --git a/frontend/src/mixins/URLQueryHandler.vue b/frontend/src/mixins/URLQueryHandler.vue index c4f3aa9..10f8c4d 100644 --- a/frontend/src/mixins/URLQueryHandler.vue +++ b/frontend/src/mixins/URLQueryHandler.vue @@ -1,6 +1,8 @@ <script lang="ts"> import _ from 'lodash' +import LocationQueryMixin from './LocationQuery.vue' + function isDate(key: string, value: string): boolean { return ( ( @@ -37,6 +39,7 @@ function encodeValue(value: string | number | boolean | Date): string { } export default { + mixins: [LocationQueryMixin], data() { return { query: this.parseQuery(window.location.href), @@ -60,10 +63,6 @@ export default { }, {}) }, - 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]') @@ -78,10 +77,10 @@ export default { }, watch: { - $route(newRoute, oldRoute) { + $route(newRoute: { fullPath: string }, oldRoute: { fullPath: string }) { const oldQuery = this.parseQuery(oldRoute.fullPath) const newQuery = this.parseQuery(newRoute.fullPath) - if (this.isQueryChanged(oldQuery, newQuery)) { + if (this.isQueryChanged({oldValue: oldQuery, newValue: newQuery})) { this.query = newQuery } }, diff --git a/frontend/src/models/LocationQuery.ts b/frontend/src/models/LocationQuery.ts index cb0db7b..f7211e1 100644 --- a/frontend/src/models/LocationQuery.ts +++ b/frontend/src/models/LocationQuery.ts @@ -8,10 +8,14 @@ class LocationQuery { public endDate: Optional<Date> = null; public minId: Optional<number> = null; public maxId: Optional<number> = null; + public minLatitude: Optional<number> = null; + public maxLatitude: Optional<number> = null; + public minLongitude: Optional<number> = null; + public maxLongitude: Optional<number> = null; public country: Optional<string> = null; public locality: Optional<string> = null; public postalCode: Optional<string> = null; - public order: string = 'asc'; + public order: string = 'desc'; constructor(data: { limit?: Optional<number>; @@ -21,6 +25,10 @@ class LocationQuery { endDate?: Optional<Date>; minId?: Optional<number>; maxId?: Optional<number>; + minLatitude?: Optional<number>; + maxLatitude?: Optional<number>; + minLongitude?: Optional<number>; + maxLongitude?: Optional<number>; country?: Optional<string>; locality?: Optional<string>; postalCode?: Optional<string>; @@ -33,16 +41,14 @@ class LocationQuery { this.endDate = data.endDate || this.endDate; this.minId = data.minId || this.minId; this.maxId = data.maxId || this.maxId; + this.minLatitude = data.minLatitude || this.minLatitude; + this.maxLatitude = data.maxLatitude || this.maxLatitude; + this.minLongitude = data.minLongitude || this.minLongitude; + this.maxLongitude = data.maxLongitude || this.maxLongitude; 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 - this.endDate = new Date(); - this.startDate = new Date(this.endDate.getTime() - 24 * 60 * 60 * 1000); - } } } diff --git a/frontend/src/views/API.vue b/frontend/src/views/API.vue index ac3cf7a..d1b68b9 100644 --- a/frontend/src/views/API.vue +++ b/frontend/src/views/API.vue @@ -19,6 +19,7 @@ <FloatingButton icon="fas fa-plus" title="Create a new API token" + :primary="true" @click="showTokenForm = true" /> </div> </template> diff --git a/frontend/src/views/Devices.vue b/frontend/src/views/Devices.vue index 29271e2..46049a6 100644 --- a/frontend/src/views/Devices.vue +++ b/frontend/src/views/Devices.vue @@ -23,6 +23,7 @@ <FloatingButton icon="fas fa-plus" title="Register a new device" + :primary="true" @click="showDeviceForm = true" /> </div> </template> diff --git a/src/requests/LocationRequest.ts b/src/requests/LocationRequest.ts index 90207d7..44b3a90 100644 --- a/src/requests/LocationRequest.ts +++ b/src/requests/LocationRequest.ts @@ -15,6 +15,10 @@ class LocationRequest { endDate: Optional<Date> = null; minId: Optional<number> = null; maxId: Optional<number> = null; + minLatitude: Optional<number> = null; + maxLatitude: Optional<number> = null; + minLongitude: Optional<number> = null; + maxLongitude: Optional<number> = null; country: Optional<string> = null; locality: Optional<string> = null; postalCode: Optional<string> = null; @@ -31,6 +35,10 @@ class LocationRequest { endDate?: Date; minId?: number; maxId?: number; + minLatitude?: number; + maxLatitude?: number; + minLongitude?: number; + maxLongitude?: number; country?: string; locality?: string; postalCode?: string; @@ -46,6 +54,10 @@ class LocationRequest { this.initDate('endDate', req); this.initNumber('minId', req); this.initNumber('maxId', req); + this.initNumber('minLatitude', req, parseFloat); + this.initNumber('maxLatitude', req, parseFloat); + this.initNumber('minLongitude', req, parseFloat); + this.initNumber('maxLongitude', req, parseFloat); this.country = req.country; this.locality = req.locality; this.postalCode = req.postalCode; @@ -54,9 +66,9 @@ class LocationRequest { this.order = (req.order || this.order).toUpperCase() as Order; } - private initNumber(key: string, req: any): void { + private initNumber(key: string, req: any, parser: (s: string) => number = parseInt): void { if (req[key] != null) { - const numValue = (this as any)[key] = parseInt(req[key]); + const numValue = (this as any)[key] = parser(req[key]); if (isNaN(numValue)) { throw new ValidationError(`Invalid value for ${key}: ${req[key]}`); } @@ -127,6 +139,30 @@ class LocationRequest { where[colMapping.description || 'description'] = {[Op.like]: `%${this.description}%`}; } + if (this.minLatitude != null || this.maxLatitude != null) { + const column = colMapping.latitude || 'latitude'; + const where_lat: any = where[column] = {}; + if (this.minLatitude == null && this.maxLatitude != null) { + where_lat[Op.lte] = this.maxLatitude; + } else if (this.minLatitude != null && this.maxLatitude == null) { + where_lat[Op.gte] = this.minLatitude; + } else { + where_lat[Op.between] = [this.minLatitude, this.maxLatitude]; + } + } + + if (this.minLongitude != null || this.maxLongitude != null) { + const column = colMapping.longitude || 'longitude'; + const where_lon: any = where[column] = {}; + if (this.minLongitude == null && this.maxLongitude != null) { + where_lon[Op.lte] = this.maxLongitude; + } else if (this.minLongitude != null && this.maxLongitude == null) { + where_lon[Op.gte] = this.minLongitude; + } else { + where_lon[Op.between] = [this.minLongitude, this.maxLongitude]; + } + } + queryMap.where = where; queryMap.order = [[colMapping[this.orderBy], this.order.toUpperCase()]]; return queryMap;