diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 04dbfc3..2133a86 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,10 +13,13 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/vue-fontawesome": "^3.0.8", + "chart.js": "^4.4.8", + "chartjs-adapter-date-fns": "^3.0.0", "countries-list": "^3.1.1", "lodash": "^4.17.21", "ol": "^10.4.0", "vue": "^3.5.13", + "vue-chartjs": "^5.3.2", "vue-router": "^4.5.0" }, "devDependencies": { @@ -1328,6 +1331,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2531,6 +2540,28 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", + "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2662,6 +2693,17 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -5556,6 +5598,16 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz", + "integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vue-eslint-parser": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 76e0c80..3c8a7f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,10 +18,13 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/vue-fontawesome": "^3.0.8", + "chart.js": "^4.4.8", + "chartjs-adapter-date-fns": "^3.0.0", "countries-list": "^3.1.1", "lodash": "^4.17.21", "ol": "^10.4.0", "vue": "^3.5.13", + "vue-chartjs": "^5.3.2", "vue-router": "^4.5.0" }, "devDependencies": { diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 051fbd5..3d899a5 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1,7 +1,8 @@ @import './base.css'; #app { - max-width: 1280px; + width: 100%; + height: 100vh; font-weight: normal; } diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue index 0aa855a..95545d0 100644 --- a/frontend/src/components/Map.vue +++ b/frontend/src/components/Map.vue @@ -1,7 +1,7 @@ <template> <main> <div class="loading" v-if="loading">Loading...</div> - <div class="map-wrapper" v-else> + <div class="map-body" v-else> <div id="map"> <div class="time-range" v-if="oldestPoint && newestPoint"> <div class="row"> @@ -46,6 +46,12 @@ :value="showControls" /> </div> </div> + + <div class="timeline"> + <Timeline :loading="loading" + :points="gpsPoints" + @point-hover="highlightPoint(pointsLayer, $event)" /> + </div> </div> </main> </template> @@ -70,6 +76,7 @@ import MapView from '../mixins/MapView.vue'; import Paginate from '../mixins/Paginate.vue'; import Points from '../mixins/Points.vue'; import Routes from '../mixins/Routes.vue'; +import Timeline from './Timeline.vue'; import URLQueryHandler from '../mixins/URLQueryHandler.vue'; useGeographic() @@ -89,13 +96,13 @@ export default { FilterButton, FilterForm, PointInfo, + Timeline, }, data() { return { loading: false, map: null as Nullable<Map>, - mappedPoints: [] as Point[], mapView: null as Nullable<View>, pointsLayer: null as Nullable<VectorLayer>, popup: null as Nullable<Overlay>, @@ -106,6 +113,21 @@ export default { } }, + computed: { + groupedGPSPoints(): GPSPoint[] { + return this.groupPoints(this.gpsPoints) + }, + + mappedPoints(): Record<string, Point> { + return this.toMappedPoints(this.groupedGPSPoints) + .reduce((acc: Record<string, Point>, point: Point) => { + // @ts-expect-error + acc[point.values_.id] = point + return acc + }, {}) + }, + }, + methods: { async fetch(): Promise<GPSPoint[]> { this.loading = true @@ -137,11 +159,10 @@ export default { this.locationQuery = prevPageQuery }, - createMap(gpsPoints: GPSPoint[]): Map { - this.mappedPoints = this.toMappedPoints(gpsPoints) - this.pointsLayer = this.createPointsLayer(this.mappedPoints as Point[]) - this.routesLayer = this.createRoutesLayer(this.mappedPoints as Point[]) - this.mapView = this.mapView || this.createMapView(gpsPoints) + createMap(): Map { + this.pointsLayer = this.createPointsLayer(Object.values(this.mappedPoints) as Point[]) + this.routesLayer = this.createRoutesLayer(Object.values(this.mappedPoints) as Point[]) + this.mapView = this.mapView || this.createMapView(this.gpsPoints) const map = new Map({ target: 'map', layers: [ @@ -254,14 +275,13 @@ export default { this.hasPrevPage = gpsPoints.length > 1 } - this.mappedPoints = this.toMappedPoints(this.gpsPoints) if (this.mapView) { // @ts-ignore this.refreshMapView(this.mapView, this.gpsPoints) // @ts-ignore - this.refreshPointsLayer(this.pointsLayer, this.mappedPoints) + this.refreshPointsLayer(this.pointsLayer, Object.values(this.mappedPoints)) // @ts-ignore - this.refreshRoutesLayer(this.routesLayer, this.mappedPoints) + this.refreshRoutesLayer(this.routesLayer, Object.values(this.mappedPoints)) } }, deep: true, @@ -271,7 +291,7 @@ export default { async mounted() { this.initQuery() this.gpsPoints = await this.fetch() - this.map = this.createMap(this.gpsPoints) + this.map = this.createMap() }, } </script> @@ -280,17 +300,33 @@ export default { @use "@/styles/common.scss" as *; @import "ol/ol.css"; +$timeline-height: 7.5em; + html, body { margin: 0; height: 100%; } +main { + width: 100%; + height: 100%; +} + +.map-body { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; +} + #map { position: absolute; top: 0; bottom: 0; width: 100%; + height: calc(100% - #{$timeline-height}); .controls { position: absolute; @@ -336,6 +372,17 @@ body { } } +.timeline { + width: 100%; + height: $timeline-height; + position: absolute; + bottom: 0; + background-color: var(--color-background); + display: flex; + justify-content: center; + align-items: center; +} + @keyframes unroll { from { transform: translateY(7.5em); diff --git a/frontend/src/components/Timeline.vue b/frontend/src/components/Timeline.vue new file mode 100644 index 0000000..051644c --- /dev/null +++ b/frontend/src/components/Timeline.vue @@ -0,0 +1,146 @@ +<template> + <div class="timeline-container"> + <h1 v-if="loading">Loading...</h1> + <h1 v-else-if="!points.length">No data to display</h1> + <div class="timeline" v-else> + <Line :data="graphData" :options="graphOptions" /> + </div> + </div> +</template> + +<script lang="ts"> +import { + CategoryScale, + Chart as ChartJS, + LineElement, + LinearScale, + PointElement, + TimeScale, + Title, + Tooltip, +} from 'chart.js'; + +import { Line } from 'vue-chartjs'; +import 'chartjs-adapter-date-fns'; + +import GPSPoint from '../models/GPSPoint'; + +ChartJS.register( + CategoryScale, + LineElement, + LinearScale, + PointElement, + TimeScale, + Title, + Tooltip, +); + +export default { + emits: ['point-hover'], + components: { + Line, + }, + + props: { + loading: { + type: Boolean, + default: false, + }, + points: { + type: Array as () => GPSPoint[], + default: () => [], + }, + }, + + computed: { + graphData() { + return { + labels: this.points.map((point: GPSPoint) => point.timestamp), + datasets: [ + { + label: 'Altitude (m)', + backgroundColor: '#7979f8', + borderColor: '#5959a8', + fill: false, + data: this.points.map((point: GPSPoint) => point.altitude), + } + ] + } + }, + + graphOptions() { + return { + responsive: true, + maintainAspectRatio: false, + elements: { + point: { + borderWidth: 1, + hoverRadius: 4, + hoverBorderWidth: 2, + }, + line: { + tension: 0.5, + borderWidth: 2, + fill: false, + } + }, + interaction: { + mode: 'index', + intersect: false, + }, + onHover: (_: MouseEvent, activeElements: any) => { + if (activeElements.length) { + const index = activeElements[0].index; + const point = this.points[index]; + this.$emit('point-hover', point); + } + }, + scales: { + x: { + type: 'time', + grid: { + drawOnChartArea: true, + drawTicks: true, + }, + time: { + tooltipFormat: 'MMM dd, HH:mm', + unit: 'minute', + }, + title: { + display: true, + text: 'Date' + }, + }, + y: { + beginAtZero: true, + title: { + display: true, + text: 'Altitude (m)' + }, + } + } + } + }, + }, +} +</script> + +<style scoped lang="scss"> +.timeline-container { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; +} + +.timeline { + width: 100%; + height: 100%; + + canvas { + width: 100% !important; + height: 100% !important; + } +} +</style> diff --git a/frontend/src/mixins/Points.vue b/frontend/src/mixins/Points.vue index 9a0c0a6..7b21013 100644 --- a/frontend/src/mixins/Points.vue +++ b/frontend/src/mixins/Points.vue @@ -16,16 +16,38 @@ const maxZoom = 18 useGeographic() +const pointStyles = { + default: 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 + }), + + highlighted: new Style({ + image: new Circle({ + radius: 10, + fill: new Fill({ color: 'rgba(255, 0, 0, 0.5)' }), + stroke: new Stroke({ color: '#FF0000', width: 2 }), + }), + zIndex: Infinity, // Ensure that points are always displayed above other layers + }), +} + export default { mixins: [Geo, Units], data() { return { metersTolerance: 20, + highlightedPointId: null, + highlightedFeature: null, } }, methods: { - groupPoints(points: GPSPoint[]) { + groupPoints(points: GPSPoint[]): GPSPoint[] { if (!points.length) { return [] } @@ -93,8 +115,12 @@ export default { }, toMappedPoints(gpsPoints: GPSPoint[]): Point[] { - return this.groupPoints(gpsPoints).map( - (gps: GPSPoint) => new Point([gps.longitude, gps.latitude]) + return gpsPoints.map( + (gps: GPSPoint) => { + const point = new Point([gps.longitude, gps.latitude]) + point.setProperties(gps) + return point + } ) }, @@ -143,6 +169,27 @@ export default { } }) }, + + highlightPoint(layer: VectorLayer, point: GPSPoint) { + const feature = layer.getSource().getClosestFeatureToCoordinate([point.longitude, point.latitude]) + if (feature) { + if (point.id === this.highlightedPointId) { + return + } + + // Reset the previous highlighted point, if any + if (this.highlightedPointId) { + const prevFeature = this.highlightedFeature + if (prevFeature) { + prevFeature.setStyle(pointStyles.default) + } + } + + feature.setStyle(pointStyles.highlighted) + this.highlightedPointId = point.id + this.highlightedFeature = feature + } + }, }, } </script> diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 8ea3bb4..1728217 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -13,3 +13,12 @@ export default { <Map /> </main> </template> + +<style lang="scss"> +main { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} +</style>