diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue index 422e9df..db2732e 100644 --- a/frontend/src/components/Map.vue +++ b/frontend/src/components/Map.vue @@ -32,6 +32,12 @@ </div> </div> + <MapCircle v-if="selectedPoint?.accuracy != null && mapObj" + :map="mapObj" + :latitude="selectedPoint.latitude" + :longitude="selectedPoint.longitude" + :radius="selectedPoint.accuracy" /> + <PointInfo :point="selectedPoint" :device="selectedPoint ? devicesById[selectedPoint?.deviceId] : null" ref="popup" @@ -94,7 +100,7 @@ <script lang="ts"> import _ from 'lodash'; import Map from 'ol/Map'; -import Overlay from 'ol/Overlay'; +import MapCircle from './MapCircle.vue'; import Point from 'ol/geom/Point'; import PointInfo from './PointInfo.vue'; import VectorLayer from 'ol/layer/Vector'; @@ -141,6 +147,7 @@ export default { FilterButton, FilterForm, FloatingButton, + MapCircle, MapSelectOverlay, PointInfo, Timeline, @@ -154,7 +161,6 @@ export default { mapView: null as Optional<View>, pointToRemove: null as Optional<GPSPoint>, pointsLayer: null as Optional<VectorLayer>, - popup: null as Optional<Overlay>, refreshPoints: 0, routesLayer: null as Optional<VectorLayer>, selectedFeature: null as Optional<Feature>, @@ -197,6 +203,14 @@ export default { return acc }, {}) }, + + mapObj(): Map | null { + if (!this.map) { + return null + } + + return this.map as Map + }, }, methods: { diff --git a/frontend/src/components/MapCircle.vue b/frontend/src/components/MapCircle.vue new file mode 100644 index 0000000..99d3d8d --- /dev/null +++ b/frontend/src/components/MapCircle.vue @@ -0,0 +1,130 @@ +<template> + <div class="circle" + ref="circle" + :style="circleStyle" /> +</template> + +<script lang="ts"> +import Geo from '../mixins/Geo.vue'; +import Map from 'ol/Map'; +import Overlay from 'ol/Overlay'; + +export default { + mixins: [Geo], + props: { + map: { + type: Map, + required: true, + }, + + latitude: { + type: Number, + required: true, + }, + + longitude: { + type: Number, + required: true, + }, + + radius: { + // Unit: meters + type: Number, + required: true, + }, + + color: { + type: String, + default: 'var(--color-accent)', + }, + + borderColor: { + type: String, + default: 'rgba(0, 0, 0, 0.2)', + }, + }, + + data() { + return { + overlay: null as Overlay | null, + } + }, + + computed: { + center(): { x: number | null, y: number | null } { + const pixel = this.map.getPixelFromCoordinate([this.longitude, this.latitude]); + if (!pixel) { + return { + x: null, + y: null, + }; + } + + return { + x: pixel[0], + y: pixel[1], + }; + }, + + circleStyle() { + if (!(this.center.x && this.center.y)) { + return {}; + } + + return { + width: `${this.radiusPx * 2}px`, + height: `${this.radiusPx * 2}px`, + backgroundColor: this.color, + border: `1px solid ${this.borderColor}`, + }; + }, + + radiusPx() { + const center = this.map.getPixelFromCoordinate([this.longitude, this.latitude]); + const radiusLatOffset = 90 * (this.radius / this.earthRadius) * Math.cos(this.latitude * Math.PI / 180); + const radius = this.map.getPixelFromCoordinate([this.longitude, this.latitude + radiusLatOffset]); + return Math.abs(center[1] - radius[1]); + }, + }, + + methods: { + bind() { + this.overlay = new Overlay({ + element: this.$el, + positioning: 'center-center', + }); + + this.map.addOverlay(this.overlay as Overlay); + + // Force show the Overlay + (this.overlay as Overlay).setPosition([this.longitude, this.latitude]); + }, + + unbind() { + if (this.overlay) { + this.map.removeOverlay(this.overlay as Overlay); + } + }, + }, + + mounted() { + this.bind(); + }, + + unmounted() { + this.unbind(); + }, +}; +</script> + +<style lang="scss" scoped> +.circle { + position: absolute; + width: 0; + height: 0; + border-radius: 50%; + transform: translate(-50%, -50%); + opacity: 0.25; + z-index: 1001; +} +</style> diff --git a/frontend/src/components/PointInfo.vue b/frontend/src/components/PointInfo.vue index 87f64fa..f3faedb 100644 --- a/frontend/src/components/PointInfo.vue +++ b/frontend/src/components/PointInfo.vue @@ -60,6 +60,12 @@ <span>{{ point.battery }}%</span> </p> + <p class="accuracy" v-if="point.accuracy"> + <font-awesome-icon icon="fas fa-ruler" /> + <span class="title">Accuracy:</span> + <span class="value">{{ point.accuracy }} m</span> + </p> + <p class="locality" v-if="point.locality">{{ point.locality }}</p> <p class="postal-code" v-if="point.postalCode">{{ point.postalCode }}</p> <p class="country" v-if="country"> @@ -353,6 +359,21 @@ export default { } } + .accuracy { + font-size: 0.9em; + font-weight: bold; + letter-spacing: 0.05em; + opacity: 0.75; + + .title { + margin: 0 0.25em; + } + + .value { + font-weight: bold; + } + } + .timestamp { color: var(--color-heading); font-weight: bold; diff --git a/frontend/src/components/Timeline.vue b/frontend/src/components/Timeline.vue index 49cfecc..1c4dd3d 100644 --- a/frontend/src/components/Timeline.vue +++ b/frontend/src/components/Timeline.vue @@ -164,7 +164,7 @@ export default { }) }, - graphData() { + graphData(): { labels: Date[], datasets: any[] } { const datasets = [] if (this.showMetrics.altitude) { datasets.push( diff --git a/frontend/src/mixins/Geo.vue b/frontend/src/mixins/Geo.vue index d79b556..9d9df83 100644 --- a/frontend/src/mixins/Geo.vue +++ b/frontend/src/mixins/Geo.vue @@ -2,9 +2,15 @@ import GPSPoint from '../models/GPSPoint' export default { + data() { + return { + earthRadius: 6378e3, // metres + } + }, + methods: { latLngToDistance(p: GPSPoint, q: GPSPoint): number { - const R = 6371e3 // metres + const R = this.earthRadius // 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 diff --git a/frontend/src/models/GPSPoint.ts b/frontend/src/models/GPSPoint.ts index 4091a8d..dc83ce1 100644 --- a/frontend/src/models/GPSPoint.ts +++ b/frontend/src/models/GPSPoint.ts @@ -1,64 +1,66 @@ +import type { Optional } from "./Types"; + class GPSPoint { public id: number; public latitude: number; public longitude: number; - public altitude: number; + public altitude?: Optional<number>; public deviceId: string; - public address: string; - public locality: string; - public country: string; - public postalCode: string; - public description?: string; - public battery?: number; - public speed?: number; - public accuracy?: number; + public address?: Optional<string>; + public locality?: Optional<string>; + public country?: Optional<string>; + public postalCode?: Optional<string>; + public description?: Optional<string>; + public battery?: Optional<number>; + public speed?: Optional<number>; + public accuracy?: Optional<number>; public timestamp: Date; - constructor({ - id, - latitude, - longitude, - altitude, - deviceId, - address, - locality, - country, - postalCode, - description, - battery, - speed, - accuracy, - timestamp, - }: { + constructor(data: { id: number; latitude: number; longitude: number; - altitude: number; + altitude?: number; deviceId: string; - address: string; - locality: string; - country: string; - postalCode: string; + address?: string; + locality?: string; + country?: string; + postalCode?: string; description?: string; battery?: number; speed?: number; accuracy?: number; - timestamp: Date; + timestamp?: Date; }) { - this.id = id; - this.latitude = latitude; - this.longitude = longitude; - this.altitude = altitude; - this.deviceId = deviceId; - this.address = address; - this.locality = locality; - this.country = country; - this.postalCode = postalCode; - this.description = description; - this.battery = battery; - this.speed = speed; - this.accuracy = accuracy; - this.timestamp = timestamp; + this.id = data.id; + this.latitude = data.latitude; + this.longitude = data.longitude; + this.altitude = data.altitude; + this.deviceId = data.deviceId; + this.address = data.address; + this.locality = data.locality; + this.country = data.country; + this.postalCode = data.postalCode; + this.description = data.description; + this.battery = data.battery; + this.speed = data.speed; + this.accuracy = data.accuracy; + this.timestamp = data.timestamp || new Date(); + } + + public static fromLatLng({ + latitude, + longitude, + }: { + latitude: number; + longitude: number; + }) { + return new GPSPoint({ + id: 0, + latitude, + longitude, + deviceId: '', + }); } }