- 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>
|
||||
<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;
|
||||
|
|
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 {
|
||||
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'
|
||||
|
|
|
@ -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;
|
||||
|
|
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>
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
|
|
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[],
|
||||
hasNextPage: 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">
|
||||
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
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
<FloatingButton
|
||||
icon="fas fa-plus"
|
||||
title="Create a new API token"
|
||||
:primary="true"
|
||||
@click="showTokenForm = true" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
<FloatingButton
|
||||
icon="fas fa-plus"
|
||||
title="Register a new device"
|
||||
:primary="true"
|
||||
@click="showDeviceForm = true" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue