Advanced filters form + split Map component into functional mixins.

This commit is contained in:
Fabio Manganiello 2025-02-23 20:43:03 +01:00
parent f800aeefea
commit 0a1e6fcf19
16 changed files with 974 additions and 161 deletions

View file

@ -8,7 +8,13 @@
"name": "gpstracker",
"version": "0.0.0",
"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",
"lodash": "^4.17.21",
"ol": "^10.4.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
@ -1136,6 +1142,73 @@
"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": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -3733,7 +3806,6 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {

View file

@ -13,7 +13,13 @@
"format": "prettier --write src/"
},
"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",
"lodash": "^4.17.21",
"ol": "^10.4.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"

View file

@ -74,6 +74,7 @@
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--color-accent: var(--vt-c-blue-fg-light);
--color-hover: var(--vt-c-blue-fg-dark);
--section-gap: 160px;
}

View file

@ -3,35 +3,56 @@
<div class="loading" v-if="loading">Loading...</div>
<div class="map-wrapper" v-else>
<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>
</main>
</template>
<script lang="ts">
import Feature from 'ol/Feature';
import GPSPoint from '../models/GPSPoint';
import Map from 'ol/Map';
import LineString from 'ol/geom/LineString';
import OSM from 'ol/source/OSM';
import Overlay from 'ol/Overlay';
import Point from 'ol/geom/Point';
import PointInfo from './PointInfo.vue';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
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 type { Nullable } from '../models/Types';
// @ts-ignore
const baseURL = __API_PATH__
import type { Nullable } from '../models/Types';
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()
export default {
mixins: [
Api,
MapView,
Points,
Routes,
URLQueryHandler,
],
components: {
FilterButton,
FilterForm,
PointInfo,
},
@ -39,159 +60,57 @@ export default {
return {
gpsPoints: [] as GPSPoint[],
loading: false,
locationQuery: new LocationQuery({}),
map: null as Nullable<Map>,
mapView: null as Nullable<View>,
pointsLayer: null as Nullable<VectorLayer>,
popup: null as Nullable<Overlay>,
routeFeatures: [] as Feature[],
routesLayer: null as Nullable<VectorLayer>,
selectedPoint: null as Nullable<GPSPoint>,
latlngTolerance: 0.001,
showControls: false,
}
},
methods: {
async fetchPoints() {
async fetch(): Promise<GPSPoint[]> {
this.loading = true
try {
const response = await fetch(`${baseURL}/gpsdata`)
return (await response.json())
.map((gps: any) => {
return new GPSPoint(gps)
})
return this.fetchPoints(this.locationQuery)
} catch (error) {
console.error(error)
return []
} finally {
this.loading = false
}
},
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 || (
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())
createMap(gpsPoints: GPSPoint[]): Map {
const points = gpsPoints.map((gps: GPSPoint) => new Point([gps.longitude, gps.latitude]))
this.pointsLayer = this.createPointsLayer(points)
this.routesLayer = this.createRoutesLayer(points)
this.mapView = this.createMapView(gpsPoints)
const map = new Map({
target: 'map',
layers: [
this.osmLayer(),
this.pointsLayer(points),
this.routeLayer(points),
this.createMapLayer(),
this.pointsLayer,
this.routesLayer,
],
view: view
view: this.mapView,
})
// @ts-expect-error
// @ts-ignore
this.$refs.popup.bindPopup(map)
this.bindClick(map)
this.bindPointerMove(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) {
map.on('click', (event) => {
this.showControls = false
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
if (feature) {
const point = this.gpsPoints.find((gps: GPSPoint) => {
const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates()
@ -200,7 +119,7 @@ export default {
if (point) {
this.selectedPoint = point
// @ts-expect-error
// @ts-ignore
this.$refs.popup.setPosition(event.coordinate)
// Center the map on the selected point
map.getView().setCenter(event.coordinate)
@ -211,38 +130,40 @@ export default {
})
},
bindPointerMove(map: Map) {
map.on('pointermove', (event) => {
const feature = map.forEachFeatureAtPixel(event.pixel, (feature) => feature)
const target = map.getTargetElement()
if (!target) {
return
}
initQuery() {
const urlQuery = this.parseQuery(window.location.href)
if (!Object.keys(urlQuery).length) {
this.setQuery(this.locationQuery)
} else {
this.locationQuery = new LocationQuery(urlQuery)
}
},
},
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 = ''
}
})
watch: {
locationQuery: {
async handler() {
this.setQuery(this.locationQuery)
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)
this.refreshPointsLayer(this.pointsLayer, points)
this.refreshRoutesLayer(this.routesLayer, points)
},
deep: true,
},
},
async mounted() {
this.gpsPoints = this.groupPoints(await this.fetchPoints())
this.initQuery()
this.gpsPoints = this.groupPoints(await this.fetch())
this.map = this.createMap(this.gpsPoints)
},
}
</script>
<style lang="scss" scoped>
@use "@/styles/common.scss" as *;
@import "ol/ol.css";
html,
@ -256,6 +177,34 @@ body {
top: 0;
bottom: 0;
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) {

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

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

View file

@ -4,8 +4,20 @@ import { createApp } from 'vue'
import App from './App.vue'
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)
app.use(router)
app.mount('#app')
.component('font-awesome-icon', FontAwesomeIcon)
.use(router)
.mount('#app')

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

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

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

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

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

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

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

View 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;
}
}

View file

@ -1,3 +1,3 @@
export function logRequest(req: any) {
console.log(`Request: ${req.method} ${req.url}`);
console.log(`[${req.ip}] ${req.method} ${req.url}`);
}