Advanced filters form + split Map component into functional mixins.
This commit is contained in:
parent
f800aeefea
commit
0a1e6fcf19
16 changed files with 974 additions and 161 deletions
74
frontend/package-lock.json
generated
74
frontend/package-lock.json
generated
|
@ -8,7 +8,13 @@
|
||||||
"name": "gpstracker",
|
"name": "gpstracker",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
"countries-list": "^3.1.1",
|
"countries-list": "^3.1.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"ol": "^10.4.0",
|
"ol": "^10.4.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
|
@ -1136,6 +1142,73 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||||
|
"version": "6.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz",
|
||||||
|
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/fontawesome-svg-core": {
|
||||||
|
"version": "6.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
|
||||||
|
"integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-common-types": "6.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/free-brands-svg-icons": {
|
||||||
|
"version": "6.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz",
|
||||||
|
"integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==",
|
||||||
|
"license": "(CC-BY-4.0 AND MIT)",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-common-types": "6.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/free-regular-svg-icons": {
|
||||||
|
"version": "6.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
|
||||||
|
"integrity": "sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==",
|
||||||
|
"license": "(CC-BY-4.0 AND MIT)",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-common-types": "6.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||||
|
"version": "6.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
|
||||||
|
"integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==",
|
||||||
|
"license": "(CC-BY-4.0 AND MIT)",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-common-types": "6.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/vue-fontawesome": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-yyHHAj4G8pQIDfaIsMvQpwKMboIZtcHTUvPqXjOHyldh1O1vZfH4W03VDPv5RvI9P6DLTzJQlmVgj9wCf7c2Fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
|
||||||
|
"vue": ">= 3.0.0 < 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
|
@ -3733,7 +3806,6 @@
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
|
|
|
@ -13,7 +13,13 @@
|
||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
"countries-list": "^3.1.1",
|
"countries-list": "^3.1.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"ol": "^10.4.0",
|
"ol": "^10.4.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
|
|
|
@ -74,6 +74,7 @@
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
--color-text: var(--vt-c-text-light-1);
|
--color-text: var(--vt-c-text-light-1);
|
||||||
--color-accent: var(--vt-c-blue-fg-light);
|
--color-accent: var(--vt-c-blue-fg-light);
|
||||||
|
--color-hover: var(--vt-c-blue-fg-dark);
|
||||||
|
|
||||||
--section-gap: 160px;
|
--section-gap: 160px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,35 +3,56 @@
|
||||||
<div class="loading" v-if="loading">Loading...</div>
|
<div class="loading" v-if="loading">Loading...</div>
|
||||||
<div class="map-wrapper" v-else>
|
<div class="map-wrapper" v-else>
|
||||||
<div id="map">
|
<div id="map">
|
||||||
<PointInfo :point="selectedPoint" ref="popup" @close="selectedPoint = null" />
|
<PointInfo :point="selectedPoint"
|
||||||
|
ref="popup"
|
||||||
|
@close="selectedPoint = null" />
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="form-container" v-if="showControls">
|
||||||
|
<FilterForm :value="locationQuery" @refresh="locationQuery = $event" />
|
||||||
|
</div>
|
||||||
|
<FilterButton @input="showControls = !showControls"
|
||||||
|
:value="showControls" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Feature from 'ol/Feature';
|
|
||||||
import GPSPoint from '../models/GPSPoint';
|
|
||||||
import Map from 'ol/Map';
|
import Map from 'ol/Map';
|
||||||
import LineString from 'ol/geom/LineString';
|
|
||||||
import OSM from 'ol/source/OSM';
|
|
||||||
import Overlay from 'ol/Overlay';
|
import Overlay from 'ol/Overlay';
|
||||||
import Point from 'ol/geom/Point';
|
import Point from 'ol/geom/Point';
|
||||||
import PointInfo from './PointInfo.vue';
|
import PointInfo from './PointInfo.vue';
|
||||||
import VectorLayer from 'ol/layer/Vector';
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
import VectorSource from 'ol/source/Vector';
|
|
||||||
import View from 'ol/View';
|
import View from 'ol/View';
|
||||||
import TileLayer from 'ol/layer/Tile';
|
|
||||||
import { Circle, Fill, Style, Stroke } from 'ol/style';
|
|
||||||
import { useGeographic } from 'ol/proj';
|
import { useGeographic } from 'ol/proj';
|
||||||
import type { Nullable } from '../models/Types';
|
|
||||||
|
|
||||||
// @ts-ignore
|
import type { Nullable } from '../models/Types';
|
||||||
const baseURL = __API_PATH__
|
import Api from '../mixins/Api.vue';
|
||||||
|
import FilterButton from './filter/ToggleButton.vue';
|
||||||
|
import FilterForm from './filter/Form.vue';
|
||||||
|
import GPSPoint from '../models/GPSPoint';
|
||||||
|
import LocationQuery from '../models/LocationQuery';
|
||||||
|
import MapView from '../mixins/MapView.vue';
|
||||||
|
import Points from '../mixins/Points.vue';
|
||||||
|
import Routes from '../mixins/Routes.vue';
|
||||||
|
import URLQueryHandler from '../mixins/URLQueryHandler.vue';
|
||||||
|
|
||||||
useGeographic()
|
useGeographic()
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [
|
||||||
|
Api,
|
||||||
|
MapView,
|
||||||
|
Points,
|
||||||
|
Routes,
|
||||||
|
URLQueryHandler,
|
||||||
|
],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
|
FilterButton,
|
||||||
|
FilterForm,
|
||||||
PointInfo,
|
PointInfo,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -39,159 +60,57 @@ export default {
|
||||||
return {
|
return {
|
||||||
gpsPoints: [] as GPSPoint[],
|
gpsPoints: [] as GPSPoint[],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
locationQuery: new LocationQuery({}),
|
||||||
map: null as Nullable<Map>,
|
map: null as Nullable<Map>,
|
||||||
|
mapView: null as Nullable<View>,
|
||||||
|
pointsLayer: null as Nullable<VectorLayer>,
|
||||||
popup: null as Nullable<Overlay>,
|
popup: null as Nullable<Overlay>,
|
||||||
routeFeatures: [] as Feature[],
|
routesLayer: null as Nullable<VectorLayer>,
|
||||||
selectedPoint: null as Nullable<GPSPoint>,
|
selectedPoint: null as Nullable<GPSPoint>,
|
||||||
latlngTolerance: 0.001,
|
showControls: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
async fetchPoints() {
|
async fetch(): Promise<GPSPoint[]> {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseURL}/gpsdata`)
|
return this.fetchPoints(this.locationQuery)
|
||||||
return (await response.json())
|
|
||||||
.map((gps: any) => {
|
|
||||||
return new GPSPoint(gps)
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
return []
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
groupPoints(points: GPSPoint[]) {
|
createMap(gpsPoints: GPSPoint[]): Map {
|
||||||
if (!points.length) {
|
const points = gpsPoints.map((gps: GPSPoint) => new Point([gps.longitude, gps.latitude]))
|
||||||
return []
|
this.pointsLayer = this.createPointsLayer(points)
|
||||||
}
|
this.routesLayer = this.createRoutesLayer(points)
|
||||||
|
this.mapView = this.createMapView(gpsPoints)
|
||||||
const groupedPoints = []
|
|
||||||
let group: GPSPoint[] = []
|
|
||||||
let prevPoint: GPSPoint = points[0]
|
|
||||||
|
|
||||||
points.forEach((point: GPSPoint, index: number) => {
|
|
||||||
if (
|
|
||||||
index === 0 || (
|
|
||||||
Math.abs(point.latitude - prevPoint.latitude) < this.latlngTolerance &&
|
|
||||||
Math.abs(point.longitude - prevPoint.longitude) < this.latlngTolerance
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
group.push(point)
|
|
||||||
} else {
|
|
||||||
if (group.length)
|
|
||||||
groupedPoints.push(group[0])
|
|
||||||
|
|
||||||
group = [point]
|
|
||||||
}
|
|
||||||
prevPoint = point
|
|
||||||
})
|
|
||||||
|
|
||||||
if (group.length)
|
|
||||||
groupedPoints.push(group[0])
|
|
||||||
|
|
||||||
return groupedPoints
|
|
||||||
},
|
|
||||||
|
|
||||||
osmLayer() {
|
|
||||||
return new TileLayer({
|
|
||||||
source: new OSM(),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
pointsLayer(points: Point[]) {
|
|
||||||
const pointFeatures = points.map((point: Point) => new Feature(point))
|
|
||||||
return new VectorLayer({
|
|
||||||
source: new VectorSource({
|
|
||||||
features: pointFeatures,
|
|
||||||
}),
|
|
||||||
style: new Style({
|
|
||||||
image: new Circle({
|
|
||||||
radius: 6,
|
|
||||||
fill: new Fill({ color: 'aquamarine' }),
|
|
||||||
stroke: new Stroke({ color: 'blue', width: 1 }),
|
|
||||||
}),
|
|
||||||
zIndex: Infinity, // Ensure that points are always displayed above other layers
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
routeLayer(points: Point[]) {
|
|
||||||
this.routeFeatures = []
|
|
||||||
points.forEach((point: Point, index: number) => {
|
|
||||||
if (index === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = new LineString([points[index - 1].getCoordinates(), point.getCoordinates()])
|
|
||||||
const routeFeature = new Feature(route)
|
|
||||||
this.routeFeatures.push(routeFeature)
|
|
||||||
})
|
|
||||||
|
|
||||||
return new VectorLayer({
|
|
||||||
source: new VectorSource({
|
|
||||||
// @ts-ignore
|
|
||||||
features: this.routeFeatures,
|
|
||||||
}),
|
|
||||||
style: new Style({
|
|
||||||
stroke: new Stroke({
|
|
||||||
color: 'cornflowerblue',
|
|
||||||
width: 2,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
createMap(gpsPoints: GPSPoint[]) {
|
|
||||||
const points = gpsPoints.map((gps: GPSPoint) => {
|
|
||||||
const point = new Point([gps.longitude, gps.latitude])
|
|
||||||
return point
|
|
||||||
});
|
|
||||||
|
|
||||||
const view = new View(this.getCenterAndZoom())
|
|
||||||
const map = new Map({
|
const map = new Map({
|
||||||
target: 'map',
|
target: 'map',
|
||||||
layers: [
|
layers: [
|
||||||
this.osmLayer(),
|
this.createMapLayer(),
|
||||||
this.pointsLayer(points),
|
this.pointsLayer,
|
||||||
this.routeLayer(points),
|
this.routesLayer,
|
||||||
],
|
],
|
||||||
view: view
|
view: this.mapView,
|
||||||
})
|
})
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-ignore
|
||||||
this.$refs.popup.bindPopup(map)
|
this.$refs.popup.bindPopup(map)
|
||||||
this.bindClick(map)
|
this.bindClick(map)
|
||||||
this.bindPointerMove(map)
|
this.bindPointerMove(map)
|
||||||
return map
|
return map
|
||||||
},
|
},
|
||||||
|
|
||||||
getCenterAndZoom() {
|
|
||||||
if (!this.gpsPoints?.length) {
|
|
||||||
return {
|
|
||||||
center: [0, 0],
|
|
||||||
zoom: 2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity]
|
|
||||||
this.gpsPoints.forEach((gps: GPSPoint) => {
|
|
||||||
minX = Math.min(minX, gps.longitude)
|
|
||||||
minY = Math.min(minY, gps.latitude)
|
|
||||||
maxX = Math.max(maxX, gps.longitude)
|
|
||||||
maxY = Math.max(maxY, gps.latitude)
|
|
||||||
})
|
|
||||||
|
|
||||||
const center = [(minX + maxX) / 2, (minY + maxY) / 2]
|
|
||||||
const zoom = Math.max(2, Math.min(18, 18 - Math.log2(Math.max(maxX - minX, maxY - minY))))
|
|
||||||
return { center, zoom }
|
|
||||||
},
|
|
||||||
|
|
||||||
bindClick(map: Map) {
|
bindClick(map: Map) {
|
||||||
map.on('click', (event) => {
|
map.on('click', (event) => {
|
||||||
|
this.showControls = false
|
||||||
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
|
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
|
||||||
|
|
||||||
if (feature) {
|
if (feature) {
|
||||||
const point = this.gpsPoints.find((gps: GPSPoint) => {
|
const point = this.gpsPoints.find((gps: GPSPoint) => {
|
||||||
const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates()
|
const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates()
|
||||||
|
@ -200,7 +119,7 @@ export default {
|
||||||
|
|
||||||
if (point) {
|
if (point) {
|
||||||
this.selectedPoint = point
|
this.selectedPoint = point
|
||||||
// @ts-expect-error
|
// @ts-ignore
|
||||||
this.$refs.popup.setPosition(event.coordinate)
|
this.$refs.popup.setPosition(event.coordinate)
|
||||||
// Center the map on the selected point
|
// Center the map on the selected point
|
||||||
map.getView().setCenter(event.coordinate)
|
map.getView().setCenter(event.coordinate)
|
||||||
|
@ -211,38 +130,40 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
bindPointerMove(map: Map) {
|
initQuery() {
|
||||||
map.on('pointermove', (event) => {
|
const urlQuery = this.parseQuery(window.location.href)
|
||||||
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
|
if (!Object.keys(urlQuery).length) {
|
||||||
const target = map.getTargetElement()
|
this.setQuery(this.locationQuery)
|
||||||
if (!target) {
|
} else {
|
||||||
return
|
this.locationQuery = new LocationQuery(urlQuery)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
if (feature) {
|
watch: {
|
||||||
// @ts-expect-error
|
locationQuery: {
|
||||||
const coords = feature.getGeometry()?.getCoordinates()
|
async handler() {
|
||||||
if (coords?.length === 2 && coords.every((coord: number) => !isNaN(coord))) {
|
this.setQuery(this.locationQuery)
|
||||||
target.title = `${coords[1].toFixed(6)}, ${coords[0].toFixed(6)}`
|
this.gpsPoints = this.groupPoints(await this.fetch())
|
||||||
}
|
const points = this.gpsPoints.map((gps: GPSPoint) => new Point([gps.longitude, gps.latitude]))
|
||||||
|
this.refreshMapView(this.mapView, this.gpsPoints)
|
||||||
target.style.cursor = 'pointer'
|
this.refreshPointsLayer(this.pointsLayer, points)
|
||||||
} else {
|
this.refreshRoutesLayer(this.routesLayer, points)
|
||||||
target.style.cursor = ''
|
},
|
||||||
target.title = ''
|
deep: true,
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
this.gpsPoints = this.groupPoints(await this.fetchPoints())
|
this.initQuery()
|
||||||
|
this.gpsPoints = this.groupPoints(await this.fetch())
|
||||||
this.map = this.createMap(this.gpsPoints)
|
this.map = this.createMap(this.gpsPoints)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/common.scss" as *;
|
||||||
@import "ol/ol.css";
|
@import "ol/ol.css";
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
@ -256,6 +177,34 @@ body {
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.5em;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
animation: unroll 0.25s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes unroll {
|
||||||
|
from {
|
||||||
|
transform: translateY(7.5em);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ol-viewport) {
|
:deep(.ol-viewport) {
|
||||||
|
|
308
frontend/src/components/filter/Form.vue
Normal file
308
frontend/src/components/filter/Form.vue
Normal file
|
@ -0,0 +1,308 @@
|
||||||
|
<template>
|
||||||
|
<form class="filter-view" @submit.prevent.stop="handleSubmit">
|
||||||
|
<h2>Filter</h2>
|
||||||
|
|
||||||
|
<div class="date-selectors">
|
||||||
|
<div class="date-selector">
|
||||||
|
<label for="start-date">Start Date</label>
|
||||||
|
<input type="datetime-local"
|
||||||
|
id="start-date"
|
||||||
|
name="start-date"
|
||||||
|
@input="newFilter.startDate = startPlusHours($event.target.value, 0)"
|
||||||
|
@change="newFilter.startDate = startPlusHours($event.target.value, 0)"
|
||||||
|
:value="toLocalString(newFilter.startDate)"
|
||||||
|
:max="maxDate" />
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)"
|
||||||
|
:disabled="!newFilter.startDate">-1w</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)"
|
||||||
|
:disabled="!newFilter.startDate">-1d</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)"
|
||||||
|
:disabled="!newFilter.startDate">-1h</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.startDate = startPlusDays(new Date(), 0)"
|
||||||
|
:disabled="!newFilter.startDate">Now</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)"
|
||||||
|
:disabled="!newFilter.startDate">+1h</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)"
|
||||||
|
:disabled="!newFilter.startDate">+1d</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)"
|
||||||
|
:disabled="!newFilter.startDate">+1w</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="date-selector">
|
||||||
|
<label for="end-date">End Date</label>
|
||||||
|
<input type="datetime-local"
|
||||||
|
id="end-date"
|
||||||
|
name="end-date"
|
||||||
|
@input="newFilter.endDate = endPlusHours($event.target.value, 0)"
|
||||||
|
@change="newFilter.endDate = endPlusHours($event.target.value, 0)"
|
||||||
|
:value="toLocalString(newFilter.endDate)"
|
||||||
|
:max="maxDate" />
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)"
|
||||||
|
:disabled="!newFilter.endDate">-1w</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)"
|
||||||
|
:disabled="!newFilter.endDate">-1d</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)"
|
||||||
|
:disabled="!newFilter.endDate">-1h</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.endDate = endPlusDays(new Date(), 0)"
|
||||||
|
:disabled="!newFilter.endDate">Now</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)"
|
||||||
|
:disabled="!newFilter.endDate">+1h</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)"
|
||||||
|
:disabled="!newFilter.endDate">+1d</button>
|
||||||
|
<button type="button"
|
||||||
|
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)"
|
||||||
|
:disabled="!newFilter.endDate">+1w</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="limit-container input-text-container">
|
||||||
|
<label for="limit">Limit</label>
|
||||||
|
<input type="number"
|
||||||
|
id="limit"
|
||||||
|
name="limit"
|
||||||
|
@input="newFilter.limit = Number($event.target.value)"
|
||||||
|
@change="newFilter.limit = Number($event.target.value)"
|
||||||
|
:value="newFilter.limit"
|
||||||
|
min="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<button type="submit" :disabled="!changed">Apply</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
emit: ['refresh'],
|
||||||
|
props: {
|
||||||
|
value: Object,
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
maxDate() {
|
||||||
|
return this.toLocalString(this.endPlusHours(new Date(), 0))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
newFilter: {...this.value},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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: Date | number | string | null): 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 | null, hours: number): Date | null {
|
||||||
|
let d = this.normalizeDate(date)
|
||||||
|
if (!d) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
d = new Date(d.getTime() + hours * 60 * 60 * 1000)
|
||||||
|
const end = this.normalizeDate(this.newFilter.endDate)
|
||||||
|
// Don't accept future dates, or dates that are greater than the current end date
|
||||||
|
if (d.getTime() > new Date().getTime() || end && d.getTime() > end.getTime()) {
|
||||||
|
return end ? new Date(end.getTime() - 60000) : new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
|
||||||
|
startPlusDays(date: Date | number | null, days: number): Date | null {
|
||||||
|
return this.startPlusHours(date, days * 24)
|
||||||
|
},
|
||||||
|
|
||||||
|
endPlusHours(date: Date | number | null, hours: number): Date | null {
|
||||||
|
let d = this.normalizeDate(date)
|
||||||
|
if (!d) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
d = new Date(d.getTime() + hours * 60 * 60 * 1000)
|
||||||
|
// Don't accept future dates
|
||||||
|
if (d.getTime() > new Date().getTime()) {
|
||||||
|
return new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or dates that are less than the current start date
|
||||||
|
const start = this.normalizeDate(this.newFilter.startDate)
|
||||||
|
if (start && d.getTime() < start.getTime()) {
|
||||||
|
return start ? new Date(start.getTime() + 60000) : new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
|
||||||
|
endPlusDays(date: Date | number | null, days: number): Date | null {
|
||||||
|
return this.endPlusHours(date, days * 24)
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSubmit() {
|
||||||
|
this.$emit('refresh', this.newFilter)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
handler(value) {
|
||||||
|
this.newFilter = {...value}
|
||||||
|
this.changed = false
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
newFilter: {
|
||||||
|
handler(value) {
|
||||||
|
this.changed = this.hasChanged(this.value, value)
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/common.scss";
|
||||||
|
|
||||||
|
.filter-view {
|
||||||
|
background: var(--color-background);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1em;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
|
||||||
|
|
||||||
|
.date-selectors {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@include common.mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.5em;
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.25em;
|
||||||
|
background: var(--color-background);
|
||||||
|
font-size: 0.75em;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-text-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.5em;
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
54
frontend/src/components/filter/ToggleButton.vue
Normal file
54
frontend/src/components/filter/ToggleButton.vue
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<template>
|
||||||
|
<button :class="{ 'selected': value }"
|
||||||
|
@click="$emit('input')"
|
||||||
|
:title="title"
|
||||||
|
:aria-pressed="value"
|
||||||
|
:aria-label="title">
|
||||||
|
<font-awesome-icon icon="fas fa-filter" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
emits: ['input'],
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
title(): string {
|
||||||
|
return this.value ? 'Hide filters' : 'Show filters'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
button {
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
width: 4em;
|
||||||
|
height: 4em;
|
||||||
|
padding: 1.5em;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 1px 1px 2px 2px var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-background);
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: inset 1px 1px 2px 2px var(--color-accent);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-background) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -4,8 +4,20 @@ import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
|
/* import the fontawesome core */
|
||||||
|
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
|
||||||
|
/* import font awesome icon component */
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
|
|
||||||
|
/* import icon kits */
|
||||||
|
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { far } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
|
/* add icons to the library */
|
||||||
|
library.add(fas, far)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
.component('font-awesome-icon', FontAwesomeIcon)
|
||||||
app.use(router)
|
.use(router)
|
||||||
|
.mount('#app')
|
||||||
app.mount('#app')
|
|
||||||
|
|
34
frontend/src/mixins/Api.vue
Normal file
34
frontend/src/mixins/Api.vue
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import GPSPoint from '../models/GPSPoint';
|
||||||
|
import LocationQuery from '../models/LocationQuery';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const baseURL = __API_PATH__
|
||||||
|
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
async fetchPoints(query: LocationQuery): Promise<GPSPoint[]> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseURL}/gpsdata?` + new URLSearchParams(
|
||||||
|
Object.entries(query).reduce((acc: any, [key, value]) => {
|
||||||
|
if (value != null && key != 'data') {
|
||||||
|
acc[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
acc[key] = value.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (await response.json())
|
||||||
|
.map((gps: any) => {
|
||||||
|
return new GPSPoint(gps)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
22
frontend/src/mixins/Geo.vue
Normal file
22
frontend/src/mixins/Geo.vue
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import GPSPoint from '../models/GPSPoint'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
latLngToDistance(p: GPSPoint, q: GPSPoint): number {
|
||||||
|
const R = 6371e3 // metres
|
||||||
|
const φ1 = p.latitude * Math.PI / 180 // φ, λ in radians
|
||||||
|
const φ2 = q.latitude * Math.PI / 180
|
||||||
|
const Δφ = (q.latitude - p.latitude) * Math.PI / 180
|
||||||
|
const Δλ = (q.longitude - p.longitude) * Math.PI / 180
|
||||||
|
|
||||||
|
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||||
|
Math.cos(φ1) * Math.cos(φ2) *
|
||||||
|
Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
|
||||||
|
return R * c // in metres
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
32
frontend/src/mixins/MapView.vue
Normal file
32
frontend/src/mixins/MapView.vue
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import OSM from 'ol/source/OSM';
|
||||||
|
import View from 'ol/View';
|
||||||
|
import TileLayer from 'ol/layer/Tile';
|
||||||
|
import { useGeographic } from 'ol/proj';
|
||||||
|
|
||||||
|
import GPSPoint from '../models/GPSPoint';
|
||||||
|
import Points from './Points.vue';
|
||||||
|
|
||||||
|
useGeographic()
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Points],
|
||||||
|
methods: {
|
||||||
|
createMapLayer(): TileLayer {
|
||||||
|
return new TileLayer({
|
||||||
|
source: new OSM(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
createMapView(points: GPSPoint[]): View {
|
||||||
|
return new View(this.getCenterAndZoom(points))
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshMapView(view: View, points: GPSPoint[]) {
|
||||||
|
const { center, zoom } = this.getCenterAndZoom(points)
|
||||||
|
view.setCenter(center)
|
||||||
|
view.setZoom(zoom)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
125
frontend/src/mixins/Points.vue
Normal file
125
frontend/src/mixins/Points.vue
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Feature from 'ol/Feature';
|
||||||
|
import Map from 'ol/Map';
|
||||||
|
import Point from 'ol/geom/Point';
|
||||||
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
|
import VectorSource from 'ol/source/Vector';
|
||||||
|
import { Circle, Fill, Style, Stroke } from 'ol/style';
|
||||||
|
import { useGeographic } from 'ol/proj';
|
||||||
|
|
||||||
|
import GPSPoint from '../models/GPSPoint';
|
||||||
|
import Geo from './Geo.vue';
|
||||||
|
|
||||||
|
const minZoom = 2
|
||||||
|
const maxZoom = 18
|
||||||
|
|
||||||
|
useGeographic()
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Geo],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
metersTolerance: 20,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
groupPoints(points: GPSPoint[]) {
|
||||||
|
if (!points.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedPoints = []
|
||||||
|
let group: GPSPoint[] = []
|
||||||
|
let prevPoint: GPSPoint = points[0]
|
||||||
|
|
||||||
|
points.forEach((point: GPSPoint, index: number) => {
|
||||||
|
if (index === 0 || this.latLngToDistance(point, prevPoint) < this.metersTolerance) {
|
||||||
|
group.push(point)
|
||||||
|
} else {
|
||||||
|
if (group.length)
|
||||||
|
groupedPoints.push(group[0])
|
||||||
|
|
||||||
|
group = [point]
|
||||||
|
}
|
||||||
|
prevPoint = point
|
||||||
|
})
|
||||||
|
|
||||||
|
if (group.length)
|
||||||
|
groupedPoints.push(group[0])
|
||||||
|
|
||||||
|
return groupedPoints
|
||||||
|
},
|
||||||
|
|
||||||
|
createPointsLayer(points: Point[]): VectorLayer {
|
||||||
|
const pointFeatures = points.map((point: Point) => new Feature(point))
|
||||||
|
return new VectorLayer({
|
||||||
|
source: new VectorSource({
|
||||||
|
features: pointFeatures,
|
||||||
|
}),
|
||||||
|
style: new Style({
|
||||||
|
image: new Circle({
|
||||||
|
radius: 6,
|
||||||
|
fill: new Fill({ color: 'aquamarine' }),
|
||||||
|
stroke: new Stroke({ color: 'blue', width: 1 }),
|
||||||
|
}),
|
||||||
|
zIndex: Infinity, // Ensure that points are always displayed above other layers
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshPointsLayer(layer: VectorLayer, points: Point[]) {
|
||||||
|
const source = layer.getSource()
|
||||||
|
source.clear()
|
||||||
|
source.addFeatures(points.map((point: Point) => new Feature(point)))
|
||||||
|
source.changed()
|
||||||
|
},
|
||||||
|
|
||||||
|
getCenterAndZoom(points: GPSPoint[]) {
|
||||||
|
if (!points?.length) {
|
||||||
|
return {
|
||||||
|
center: [0, 0],
|
||||||
|
zoom: minZoom,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity]
|
||||||
|
points.forEach((gps: GPSPoint) => {
|
||||||
|
minX = Math.min(minX, gps.longitude)
|
||||||
|
minY = Math.min(minY, gps.latitude)
|
||||||
|
maxX = Math.max(maxX, gps.longitude)
|
||||||
|
maxY = Math.max(maxY, gps.latitude)
|
||||||
|
})
|
||||||
|
|
||||||
|
const center = [(minX + maxX) / 2, (minY + maxY) / 2]
|
||||||
|
const winScaleMultiplier = (window.innerHeight / window.innerWidth) * 2560
|
||||||
|
const logDisplacement = Math.log2(Math.max(maxX - minX, maxY - minY) * winScaleMultiplier)
|
||||||
|
const zoom = Math.max(minZoom, Math.min(maxZoom, (maxZoom + 2) - logDisplacement))
|
||||||
|
return { center, zoom }
|
||||||
|
},
|
||||||
|
|
||||||
|
bindPointerMove(map: Map) {
|
||||||
|
map.on('pointermove', (event) => {
|
||||||
|
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
|
||||||
|
const target = map.getTargetElement()
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature) {
|
||||||
|
// @ts-expect-error
|
||||||
|
const coords = feature.getGeometry()?.getCoordinates()
|
||||||
|
if (coords?.length === 2 && coords.every((coord: number) => !isNaN(coord))) {
|
||||||
|
target.title = `${coords[1].toFixed(6)}, ${coords[0].toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
target.style.cursor = 'pointer'
|
||||||
|
} else {
|
||||||
|
target.style.cursor = ''
|
||||||
|
target.title = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
53
frontend/src/mixins/Routes.vue
Normal file
53
frontend/src/mixins/Routes.vue
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Feature from 'ol/Feature';
|
||||||
|
import LineString from 'ol/geom/LineString';
|
||||||
|
import Point from 'ol/geom/Point';
|
||||||
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
|
import VectorSource from 'ol/source/Vector';
|
||||||
|
import { Style, Stroke } from 'ol/style';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
createRoutesLayer(points: Point[]) {
|
||||||
|
const routeFeatures = this.extractRouteFeatures(points)
|
||||||
|
return new VectorLayer({
|
||||||
|
source: new VectorSource({
|
||||||
|
// @ts-ignore
|
||||||
|
features: routeFeatures,
|
||||||
|
}),
|
||||||
|
style: new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'cornflowerblue',
|
||||||
|
width: 3,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshRoutesLayer(layer: VectorLayer, points: Point[]) {
|
||||||
|
const routeFeatures = this.extractRouteFeatures(points)
|
||||||
|
const source = layer.getSource()
|
||||||
|
source.clear()
|
||||||
|
source.addFeatures(routeFeatures)
|
||||||
|
source.changed()
|
||||||
|
},
|
||||||
|
|
||||||
|
extractRouteFeatures(points: Point[]): Feature[] {
|
||||||
|
const routeFeatures = []
|
||||||
|
points.forEach((point: Point, index: number) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = new LineString(
|
||||||
|
[points[index - 1].getCoordinates(), point.getCoordinates()]
|
||||||
|
)
|
||||||
|
const routeFeature = new Feature(route)
|
||||||
|
routeFeatures.push(routeFeature)
|
||||||
|
})
|
||||||
|
|
||||||
|
return routeFeatures
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
90
frontend/src/mixins/URLQueryHandler.vue
Normal file
90
frontend/src/mixins/URLQueryHandler.vue
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
function isDate(key: string, value: string): boolean {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
key.toLowerCase().endsWith('date') ||
|
||||||
|
key.toLowerCase().endsWith('time') ||
|
||||||
|
key.toLowerCase().endsWith('timestamp')
|
||||||
|
) && new Date(value).toString() !== 'Invalid Date'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(key: string, value: string | null): string | number | boolean | Date {
|
||||||
|
value = decodeURI(value?.trim() || '')
|
||||||
|
if (!value.length) {
|
||||||
|
return undefined
|
||||||
|
} else if (value.toLowerCase() === 'true') {
|
||||||
|
return true
|
||||||
|
} else if (value.toLowerCase() === 'false') {
|
||||||
|
return false
|
||||||
|
} else if (isDate(key, value)) {
|
||||||
|
return new Date(value)
|
||||||
|
} else if (!isNaN(Number(value))) {
|
||||||
|
return Number(value)
|
||||||
|
} else {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeValue(value: string | number | boolean | Date): string {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.getTime().toString()
|
||||||
|
} else {
|
||||||
|
return encodeURI(value.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
query: this.parseQuery(window.location.href),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
parseQuery(query: string): Record<string, string> {
|
||||||
|
return query
|
||||||
|
.replace(/^[^#]*#?(.*)/, (_, hash) => hash)
|
||||||
|
.split('&')
|
||||||
|
.reduce((acc: Record<string, any>, pair: string) => {
|
||||||
|
const [key, value] = pair.split('=', 2).map((part: string) => part.trim())
|
||||||
|
if (key.length) {
|
||||||
|
const v = parseValue(key, value)
|
||||||
|
if (v != null) {
|
||||||
|
acc[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
},
|
||||||
|
|
||||||
|
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]')
|
||||||
|
.map(([key, value]) => `${key}=${encodeValue(value)}`)
|
||||||
|
.join('&')
|
||||||
|
},
|
||||||
|
|
||||||
|
setQuery(values: Record<string, any>) {
|
||||||
|
const newQuery = this.toQueryString(values)
|
||||||
|
window.location.hash = newQuery
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
$route(newRoute, oldRoute) {
|
||||||
|
const oldQuery = this.parseQuery(oldRoute.fullPath)
|
||||||
|
const newQuery = this.parseQuery(newRoute.fullPath)
|
||||||
|
if (this.isQueryChanged(oldQuery, newQuery)) {
|
||||||
|
this.query = newQuery
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
31
frontend/src/models/LocationQuery.ts
Normal file
31
frontend/src/models/LocationQuery.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
class LocationQuery {
|
||||||
|
public limit: number = 250;
|
||||||
|
public offset: number = 0;
|
||||||
|
public startDate: Date | null = null;
|
||||||
|
public endDate: Date | null = null;
|
||||||
|
public minId: number | null = null;
|
||||||
|
public maxId: number | null = null;
|
||||||
|
public country: string | null = null;
|
||||||
|
public locality: string | null = null;
|
||||||
|
public postalCode: string | null = null;
|
||||||
|
|
||||||
|
constructor(public data: any) {
|
||||||
|
this.limit = data.limit || this.limit;
|
||||||
|
this.offset = data.offset || this.offset;
|
||||||
|
this.startDate = data.startDate || this.startDate;
|
||||||
|
this.endDate = data.endDate || this.endDate;
|
||||||
|
this.minId = data.minId || this.minId;
|
||||||
|
this.maxId = data.maxId || this.maxId;
|
||||||
|
this.country = data.country || this.country;
|
||||||
|
this.locality = data.locality || this.locality;
|
||||||
|
this.postalCode = data.postalCode || this.postalCode;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LocationQuery;
|
24
frontend/src/styles/common.scss
Normal file
24
frontend/src/styles/common.scss
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
// Set screen width breakpoints
|
||||||
|
$screen-xs: 480px;
|
||||||
|
$screen-sm: 768px;
|
||||||
|
$screen-md: 992px;
|
||||||
|
$screen-lg: 1200px;
|
||||||
|
|
||||||
|
// @media utilities for common screen sizes
|
||||||
|
@mixin mobile {
|
||||||
|
@media (max-width: $screen-sm) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin tablet {
|
||||||
|
@media (min-width: $screen-sm) and (max-width: $screen-md) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin desktop {
|
||||||
|
@media (min-width: $screen-md) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,3 @@
|
||||||
export function logRequest(req: any) {
|
export function logRequest(req: any) {
|
||||||
console.log(`Request: ${req.method} ${req.url}`);
|
console.log(`[${req.ip}] ${req.method} ${req.url}`);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue