Several UI improvements and features.

- Added support for filtering location data in a certain area.

- Made date range selection optional.

- A more robust way to detect changes in the location filter.

- Let the timeline graph set the default time ticks.
This commit is contained in:
Fabio Manganiello 2025-03-29 23:53:49 +01:00
parent 35821dbccd
commit 282828df0b
Signed by: blacklight
GPG key ID: D90FBA7F76362774
15 changed files with 603 additions and 100 deletions

View file

@ -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;

View file

@ -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>

View file

@ -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'

View file

@ -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;

View file

@ -0,0 +1 @@
$timeline-height: 10rem;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -8,7 +8,7 @@ export default {
gpsPoints: [] as GPSPoint[],
hasNextPage: true,
hasPrevPage: true,
locationQuery: new LocationQuery({}),
locationQuery: new LocationQuery({}) as LocationQuery,
}
},

View file

@ -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>

View file

@ -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
}
},

View file

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

View file

@ -19,6 +19,7 @@
<FloatingButton
icon="fas fa-plus"
title="Create a new API token"
:primary="true"
@click="showTokenForm = true" />
</div>
</template>

View file

@ -23,6 +23,7 @@
<FloatingButton
icon="fas fa-plus"
title="Register a new device"
:primary="true"
@click="showDeviceForm = true" />
</div>
</template>

View file

@ -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;