- 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:
parent
35821dbccd
commit
282828df0b
15 changed files with 603 additions and 100 deletions
frontend/src
components
elements
mixins
models
views
src/requests
|
@ -2,6 +2,11 @@
|
||||||
<main>
|
<main>
|
||||||
<div class="loading" v-if="loading">Loading...</div>
|
<div class="loading" v-if="loading">Loading...</div>
|
||||||
<div class="map-body" v-else>
|
<div class="map-body" v-else>
|
||||||
|
<MapSelectOverlay
|
||||||
|
@close="showSelectOverlay = false"
|
||||||
|
@select="onAreaSelect"
|
||||||
|
v-if="showSelectOverlay" />
|
||||||
|
|
||||||
<div id="map">
|
<div id="map">
|
||||||
<div class="time-range" v-if="oldestPoint && newestPoint">
|
<div class="time-range" v-if="oldestPoint && newestPoint">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@ -51,6 +56,16 @@
|
||||||
<FilterButton @input="showControls = !showControls"
|
<FilterButton @input="showControls = !showControls"
|
||||||
:value="showControls" />
|
:value="showControls" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
|
@ -94,8 +109,11 @@ import Dates from '../mixins/Dates.vue';
|
||||||
import Feature from 'ol/Feature';
|
import Feature from 'ol/Feature';
|
||||||
import FilterButton from './filter/ToggleButton.vue';
|
import FilterButton from './filter/ToggleButton.vue';
|
||||||
import FilterForm from './filter/Form.vue';
|
import FilterForm from './filter/Form.vue';
|
||||||
|
import FloatingButton from '../elements/FloatingButton.vue';
|
||||||
import GPSPoint from '../models/GPSPoint';
|
import GPSPoint from '../models/GPSPoint';
|
||||||
import LocationQuery from '../models/LocationQuery';
|
import LocationQuery from '../models/LocationQuery';
|
||||||
|
import LocationQueryMixin from '../mixins/LocationQuery.vue';
|
||||||
|
import MapSelectOverlay from './MapSelectOverlay.vue';
|
||||||
import MapView from '../mixins/MapView.vue';
|
import MapView from '../mixins/MapView.vue';
|
||||||
import Paginate from '../mixins/Paginate.vue';
|
import Paginate from '../mixins/Paginate.vue';
|
||||||
import Points from '../mixins/Points.vue';
|
import Points from '../mixins/Points.vue';
|
||||||
|
@ -111,6 +129,7 @@ export default {
|
||||||
mixins: [
|
mixins: [
|
||||||
Api,
|
Api,
|
||||||
Dates,
|
Dates,
|
||||||
|
LocationQueryMixin,
|
||||||
MapView,
|
MapView,
|
||||||
Paginate,
|
Paginate,
|
||||||
Points,
|
Points,
|
||||||
|
@ -122,6 +141,8 @@ export default {
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
FilterButton,
|
FilterButton,
|
||||||
FilterForm,
|
FilterForm,
|
||||||
|
FloatingButton,
|
||||||
|
MapSelectOverlay,
|
||||||
PointInfo,
|
PointInfo,
|
||||||
Timeline,
|
Timeline,
|
||||||
},
|
},
|
||||||
|
@ -135,7 +156,6 @@ export default {
|
||||||
pointToRemove: null as Optional<GPSPoint>,
|
pointToRemove: null as Optional<GPSPoint>,
|
||||||
pointsLayer: null as Optional<VectorLayer>,
|
pointsLayer: null as Optional<VectorLayer>,
|
||||||
popup: null as Optional<Overlay>,
|
popup: null as Optional<Overlay>,
|
||||||
queryInitialized: false,
|
|
||||||
refreshPoints: 0,
|
refreshPoints: 0,
|
||||||
routesLayer: null as Optional<VectorLayer>,
|
routesLayer: null as Optional<VectorLayer>,
|
||||||
selectedFeature: null as Optional<Feature>,
|
selectedFeature: null as Optional<Feature>,
|
||||||
|
@ -143,6 +163,7 @@ export default {
|
||||||
selectedPointIndex: null as Optional<number>,
|
selectedPointIndex: null as Optional<number>,
|
||||||
showControls: false,
|
showControls: false,
|
||||||
showMetrics: new TimelineMetricsConfiguration(),
|
showMetrics: new TimelineMetricsConfiguration(),
|
||||||
|
showSelectOverlay: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -160,6 +181,13 @@ export default {
|
||||||
return this.groupPoints(this.gpsPoints)
|
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> {
|
mappedPoints(): Record<string, Point> {
|
||||||
// Reference refreshPoints to force reactivity
|
// Reference refreshPoints to force reactivity
|
||||||
this.refreshPoints;
|
this.refreshPoints;
|
||||||
|
@ -378,26 +406,72 @@ export default {
|
||||||
setShowMetrics(metrics: any) {
|
setShowMetrics(metrics: any) {
|
||||||
Object.assign(this.showMetrics, metrics)
|
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: {
|
watch: {
|
||||||
locationQuery: {
|
locationQuery: {
|
||||||
async handler(newQuery, oldQuery) {
|
async handler(newQuery: LocationQuery, oldQuery: LocationQuery) {
|
||||||
const isFirstQuery = !this.queryInitialized
|
if (!this.isQueryChanged({
|
||||||
|
newValue: newQuery,
|
||||||
|
oldValue: oldQuery,
|
||||||
|
})) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If startDate/endDate have changed, reset minId/maxId
|
// If startDate/endDate have changed, reset minId/maxId
|
||||||
if (!isFirstQuery &&
|
if (newQuery.startDate !== oldQuery.startDate || newQuery.endDate !== oldQuery.endDate) {
|
||||||
(newQuery.startDate !== oldQuery.startDate || newQuery.endDate !== oldQuery.endDate)
|
|
||||||
) {
|
|
||||||
newQuery.minId = null
|
newQuery.minId = null
|
||||||
newQuery.maxId = null
|
newQuery.maxId = null
|
||||||
this.hasNextPage = true
|
this.hasNextPage = true
|
||||||
this.hasPrevPage = true
|
this.hasPrevPage = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Results with maxId should be retrieved in descending order,
|
newQuery.order = 'desc'
|
||||||
// otherwise all results should be retrieved in ascending order
|
|
||||||
newQuery.order = newQuery.maxId ? 'desc' : 'asc'
|
|
||||||
this.setQuery(
|
this.setQuery(
|
||||||
{
|
{
|
||||||
...newQuery,
|
...newQuery,
|
||||||
|
@ -406,33 +480,29 @@ export default {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
this.queryInitialized = true
|
const gpsPoints = await this.fetch()
|
||||||
|
|
||||||
if (!isFirstQuery) {
|
// If there are no points, and minId/maxId are set, reset them
|
||||||
const gpsPoints = await this.fetch()
|
// and don't update the map (it means that we have reached the
|
||||||
|
// start/end of the current window)
|
||||||
// If there are no points, and minId/maxId are set, reset them
|
if (gpsPoints.length < 2 && (newQuery.minId || newQuery.maxId)) {
|
||||||
// and don't update the map (it means that we have reached the
|
if (newQuery.minId) {
|
||||||
// start/end of the current window)
|
this.hasNextPage = false
|
||||||
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
|
if (newQuery.maxId) {
|
||||||
this.hasNextPage = gpsPoints.length > 1
|
this.hasPrevPage = false
|
||||||
this.hasPrevPage = gpsPoints.length > 1
|
}
|
||||||
|
|
||||||
|
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) {
|
if (this.mapView) {
|
||||||
this.refreshMap()
|
this.refreshMap()
|
||||||
}
|
}
|
||||||
|
@ -474,10 +544,9 @@ export default {
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use "@/styles/common.scss" as *;
|
@use "@/styles/common.scss" as *;
|
||||||
|
@use "./vars.scss" as *;
|
||||||
@import "ol/ol.css";
|
@import "ol/ol.css";
|
||||||
|
|
||||||
$timeline-height: 10rem;
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -556,6 +625,25 @@ main {
|
||||||
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.5);
|
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) {
|
:deep(.ol-viewport) {
|
||||||
.ol-attribution {
|
.ol-attribution {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
192
frontend/src/components/MapSelectOverlay.vue
Normal file
192
frontend/src/components/MapSelectOverlay.vue
Normal 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>
|
|
@ -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 {
|
return {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
@ -245,9 +251,9 @@ export default {
|
||||||
drawTicks: true,
|
drawTicks: true,
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
tooltipFormat: 'MMM dd, HH:mm',
|
tooltipFormat: 'MMM dd yyyy, HH:mm',
|
||||||
unit: 'minute',
|
|
||||||
},
|
},
|
||||||
|
ticks: xTicks,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Date'
|
text: 'Date'
|
||||||
|
|
|
@ -2,7 +2,16 @@
|
||||||
<form class="filter-view" @submit.prevent.stop="handleSubmit">
|
<form class="filter-view" @submit.prevent.stop="handleSubmit">
|
||||||
<h2>Filter</h2>
|
<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">
|
<div class="date-selector">
|
||||||
<label for="start-date">Start Date</label>
|
<label for="start-date">Start Date</label>
|
||||||
<input type="datetime-local"
|
<input type="datetime-local"
|
||||||
|
@ -160,9 +169,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
import LocationQuery from '../../models/LocationQuery'
|
||||||
|
import LocationQueryMixin from '../../mixins/LocationQuery.vue'
|
||||||
import UserDevice from '../../models/UserDevice'
|
import UserDevice from '../../models/UserDevice'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [LocationQueryMixin],
|
||||||
emit: [
|
emit: [
|
||||||
'next-page',
|
'next-page',
|
||||||
'prev-page',
|
'prev-page',
|
||||||
|
@ -172,7 +184,7 @@ export default {
|
||||||
],
|
],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
value: Object,
|
value: LocationQuery,
|
||||||
devices: {
|
devices: {
|
||||||
type: Array as () => UserDevice[],
|
type: Array as () => UserDevice[],
|
||||||
default: () => [],
|
default: () => [],
|
||||||
|
@ -204,51 +216,13 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
changed: false,
|
changed: false,
|
||||||
|
enableDateRange: false,
|
||||||
newFilter: {...this.value},
|
newFilter: {...this.value},
|
||||||
newResolution: this.resolution,
|
newResolution: this.resolution,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
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 {
|
startPlusHours(date: Date | number | Event | undefined | null, hours: number): Date | null {
|
||||||
if ((date as any)?.target?.value) {
|
if ((date as any)?.target?.value) {
|
||||||
date = (date as any).target.value
|
date = (date as any).target.value
|
||||||
|
@ -316,11 +290,19 @@ export default {
|
||||||
setResolution(event: Event) {
|
setResolution(event: Event) {
|
||||||
this.newResolution = Number((event.target as HTMLInputElement).value)
|
this.newResolution = Number((event.target as HTMLInputElement).value)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
initDateRange(value: LocationQuery) {
|
||||||
|
this.enableDateRange = !!(value.startDate && value.endDate)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.initDateRange(this.value)
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
value: {
|
value: {
|
||||||
handler(value) {
|
handler(value: LocationQuery) {
|
||||||
this.newFilter = {...value}
|
this.newFilter = {...value}
|
||||||
this.changed = false
|
this.changed = false
|
||||||
},
|
},
|
||||||
|
@ -329,16 +311,35 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
newFilter: {
|
newFilter: {
|
||||||
handler(value) {
|
handler(value: LocationQuery) {
|
||||||
this.changed = this.hasChanged(this.value, value)
|
this.changed = this.isQueryChanged({
|
||||||
|
newValue: value,
|
||||||
|
oldValue: this.value
|
||||||
|
})
|
||||||
|
this.initDateRange(value)
|
||||||
},
|
},
|
||||||
immediate: true,
|
immediate: true,
|
||||||
deep: true,
|
deep: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
newResolution(value) {
|
newResolution(value: number) {
|
||||||
this.changed = this.changed || value !== this.resolution
|
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>
|
</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 {
|
.pagination-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
1
frontend/src/components/vars.scss
Normal file
1
frontend/src/components/vars.scss
Normal file
|
@ -0,0 +1 @@
|
||||||
|
$timeline-height: 10rem;
|
|
@ -1,7 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<button class="floating-button"
|
<button class="floating-button"
|
||||||
|
:class="{ primary }"
|
||||||
@click="$emit('click')"
|
@click="$emit('click')"
|
||||||
:title="title"
|
:title="title"
|
||||||
|
:style="style"
|
||||||
:aria-label="title">
|
:aria-label="title">
|
||||||
<font-awesome-icon :icon="icon" />
|
<font-awesome-icon :icon="icon" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -16,6 +18,16 @@ export default {
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
primary: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
style: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
@ -28,8 +40,6 @@ button.floating-button {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1em;
|
bottom: 1em;
|
||||||
right: 1em;
|
right: 1em;
|
||||||
background: var(--color-accent);
|
|
||||||
color: var(--color-background);
|
|
||||||
width: 4em;
|
width: 4em;
|
||||||
height: 4em;
|
height: 4em;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
|
@ -42,10 +52,20 @@ button.floating-button {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-accent) !important;
|
|
||||||
color: var(--color-background) !important;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
filter: brightness(1.2);
|
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>
|
</style>
|
||||||
|
|
|
@ -8,6 +8,30 @@ export default {
|
||||||
|
|
||||||
return new Date(date).toString().replace(/GMT.*/, '')
|
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>
|
</script>
|
||||||
|
|
32
frontend/src/mixins/LocationQuery.vue
Normal file
32
frontend/src/mixins/LocationQuery.vue
Normal 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>
|
|
@ -8,7 +8,7 @@ export default {
|
||||||
gpsPoints: [] as GPSPoint[],
|
gpsPoints: [] as GPSPoint[],
|
||||||
hasNextPage: true,
|
hasNextPage: true,
|
||||||
hasPrevPage: true,
|
hasPrevPage: true,
|
||||||
locationQuery: new LocationQuery({}),
|
locationQuery: new LocationQuery({}) as LocationQuery,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
86
frontend/src/mixins/SelectionBox.vue
Normal file
86
frontend/src/mixins/SelectionBox.vue
Normal 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>
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
import LocationQueryMixin from './LocationQuery.vue'
|
||||||
|
|
||||||
function isDate(key: string, value: string): boolean {
|
function isDate(key: string, value: string): boolean {
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
|
@ -37,6 +39,7 @@ function encodeValue(value: string | number | boolean | Date): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [LocationQueryMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
query: this.parseQuery(window.location.href),
|
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>) {
|
toQueryString(values: Record<string, any>) {
|
||||||
return Object.entries(values)
|
return Object.entries(values)
|
||||||
.filter(([_, value]) => value != null && value.toString() !== '[object Object]')
|
.filter(([_, value]) => value != null && value.toString() !== '[object Object]')
|
||||||
|
@ -78,10 +77,10 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
$route(newRoute, oldRoute) {
|
$route(newRoute: { fullPath: string }, oldRoute: { fullPath: string }) {
|
||||||
const oldQuery = this.parseQuery(oldRoute.fullPath)
|
const oldQuery = this.parseQuery(oldRoute.fullPath)
|
||||||
const newQuery = this.parseQuery(newRoute.fullPath)
|
const newQuery = this.parseQuery(newRoute.fullPath)
|
||||||
if (this.isQueryChanged(oldQuery, newQuery)) {
|
if (this.isQueryChanged({oldValue: oldQuery, newValue: newQuery})) {
|
||||||
this.query = newQuery
|
this.query = newQuery
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -8,10 +8,14 @@ class LocationQuery {
|
||||||
public endDate: Optional<Date> = null;
|
public endDate: Optional<Date> = null;
|
||||||
public minId: Optional<number> = null;
|
public minId: Optional<number> = null;
|
||||||
public maxId: 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 country: Optional<string> = null;
|
||||||
public locality: Optional<string> = null;
|
public locality: Optional<string> = null;
|
||||||
public postalCode: Optional<string> = null;
|
public postalCode: Optional<string> = null;
|
||||||
public order: string = 'asc';
|
public order: string = 'desc';
|
||||||
|
|
||||||
constructor(data: {
|
constructor(data: {
|
||||||
limit?: Optional<number>;
|
limit?: Optional<number>;
|
||||||
|
@ -21,6 +25,10 @@ class LocationQuery {
|
||||||
endDate?: Optional<Date>;
|
endDate?: Optional<Date>;
|
||||||
minId?: Optional<number>;
|
minId?: Optional<number>;
|
||||||
maxId?: Optional<number>;
|
maxId?: Optional<number>;
|
||||||
|
minLatitude?: Optional<number>;
|
||||||
|
maxLatitude?: Optional<number>;
|
||||||
|
minLongitude?: Optional<number>;
|
||||||
|
maxLongitude?: Optional<number>;
|
||||||
country?: Optional<string>;
|
country?: Optional<string>;
|
||||||
locality?: Optional<string>;
|
locality?: Optional<string>;
|
||||||
postalCode?: Optional<string>;
|
postalCode?: Optional<string>;
|
||||||
|
@ -33,16 +41,14 @@ class LocationQuery {
|
||||||
this.endDate = data.endDate || this.endDate;
|
this.endDate = data.endDate || this.endDate;
|
||||||
this.minId = data.minId || this.minId;
|
this.minId = data.minId || this.minId;
|
||||||
this.maxId = data.maxId || this.maxId;
|
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.country = data.country || this.country;
|
||||||
this.locality = data.locality || this.locality;
|
this.locality = data.locality || this.locality;
|
||||||
this.postalCode = data.postalCode || this.postalCode;
|
this.postalCode = data.postalCode || this.postalCode;
|
||||||
this.order = data.order || this.order;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
<FloatingButton
|
<FloatingButton
|
||||||
icon="fas fa-plus"
|
icon="fas fa-plus"
|
||||||
title="Create a new API token"
|
title="Create a new API token"
|
||||||
|
:primary="true"
|
||||||
@click="showTokenForm = true" />
|
@click="showTokenForm = true" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
<FloatingButton
|
<FloatingButton
|
||||||
icon="fas fa-plus"
|
icon="fas fa-plus"
|
||||||
title="Register a new device"
|
title="Register a new device"
|
||||||
|
:primary="true"
|
||||||
@click="showDeviceForm = true" />
|
@click="showDeviceForm = true" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -15,6 +15,10 @@ class LocationRequest {
|
||||||
endDate: Optional<Date> = null;
|
endDate: Optional<Date> = null;
|
||||||
minId: Optional<number> = null;
|
minId: Optional<number> = null;
|
||||||
maxId: 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;
|
country: Optional<string> = null;
|
||||||
locality: Optional<string> = null;
|
locality: Optional<string> = null;
|
||||||
postalCode: Optional<string> = null;
|
postalCode: Optional<string> = null;
|
||||||
|
@ -31,6 +35,10 @@ class LocationRequest {
|
||||||
endDate?: Date;
|
endDate?: Date;
|
||||||
minId?: number;
|
minId?: number;
|
||||||
maxId?: number;
|
maxId?: number;
|
||||||
|
minLatitude?: number;
|
||||||
|
maxLatitude?: number;
|
||||||
|
minLongitude?: number;
|
||||||
|
maxLongitude?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
locality?: string;
|
locality?: string;
|
||||||
postalCode?: string;
|
postalCode?: string;
|
||||||
|
@ -46,6 +54,10 @@ class LocationRequest {
|
||||||
this.initDate('endDate', req);
|
this.initDate('endDate', req);
|
||||||
this.initNumber('minId', req);
|
this.initNumber('minId', req);
|
||||||
this.initNumber('maxId', 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.country = req.country;
|
||||||
this.locality = req.locality;
|
this.locality = req.locality;
|
||||||
this.postalCode = req.postalCode;
|
this.postalCode = req.postalCode;
|
||||||
|
@ -54,9 +66,9 @@ class LocationRequest {
|
||||||
this.order = (req.order || this.order).toUpperCase() as Order;
|
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) {
|
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)) {
|
if (isNaN(numValue)) {
|
||||||
throw new ValidationError(`Invalid value for ${key}: ${req[key]}`);
|
throw new ValidationError(`Invalid value for ${key}: ${req[key]}`);
|
||||||
}
|
}
|
||||||
|
@ -127,6 +139,30 @@ class LocationRequest {
|
||||||
where[colMapping.description || 'description'] = {[Op.like]: `%${this.description}%`};
|
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.where = where;
|
||||||
queryMap.order = [[colMapping[this.orderBy], this.order.toUpperCase()]];
|
queryMap.order = [[colMapping[this.orderBy], this.order.toUpperCase()]];
|
||||||
return queryMap;
|
return queryMap;
|
||||||
|
|
Loading…
Add table
Reference in a new issue