Added timeline with points shown on hover.

This commit is contained in:
Fabio Manganiello 2025-02-26 03:09:15 +01:00
parent b7c6ae1f55
commit a833d43586
Signed by: blacklight
GPG key ID: D90FBA7F76362774
7 changed files with 320 additions and 15 deletions

View file

@ -13,10 +13,13 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.0.8", "@fortawesome/vue-fontawesome": "^3.0.8",
"chart.js": "^4.4.8",
"chartjs-adapter-date-fns": "^3.0.0",
"countries-list": "^3.1.1", "countries-list": "^3.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"ol": "^10.4.0", "ol": "^10.4.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
@ -1328,6 +1331,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "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" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2662,6 +2693,17 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "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": { "node_modules/de-indent": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", "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": { "node_modules/vue-eslint-parser": {
"version": "9.4.3", "version": "9.4.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",

View file

@ -18,10 +18,13 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.0.8", "@fortawesome/vue-fontawesome": "^3.0.8",
"chart.js": "^4.4.8",
"chartjs-adapter-date-fns": "^3.0.0",
"countries-list": "^3.1.1", "countries-list": "^3.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"ol": "^10.4.0", "ol": "^10.4.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,7 +1,8 @@
@import './base.css'; @import './base.css';
#app { #app {
max-width: 1280px; width: 100%;
height: 100vh;
font-weight: normal; font-weight: normal;
} }

View file

@ -1,7 +1,7 @@
<template> <template>
<main> <main>
<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-body" v-else>
<div id="map"> <div id="map">
<div class="time-range" v-if="oldestPoint && newestPoint"> <div class="time-range" v-if="oldestPoint && newestPoint">
<div class="row"> <div class="row">
@ -46,6 +46,12 @@
:value="showControls" /> :value="showControls" />
</div> </div>
</div> </div>
<div class="timeline">
<Timeline :loading="loading"
:points="gpsPoints"
@point-hover="highlightPoint(pointsLayer, $event)" />
</div>
</div> </div>
</main> </main>
</template> </template>
@ -70,6 +76,7 @@ import MapView from '../mixins/MapView.vue';
import Paginate from '../mixins/Paginate.vue'; import Paginate from '../mixins/Paginate.vue';
import Points from '../mixins/Points.vue'; import Points from '../mixins/Points.vue';
import Routes from '../mixins/Routes.vue'; import Routes from '../mixins/Routes.vue';
import Timeline from './Timeline.vue';
import URLQueryHandler from '../mixins/URLQueryHandler.vue'; import URLQueryHandler from '../mixins/URLQueryHandler.vue';
useGeographic() useGeographic()
@ -89,13 +96,13 @@ export default {
FilterButton, FilterButton,
FilterForm, FilterForm,
PointInfo, PointInfo,
Timeline,
}, },
data() { data() {
return { return {
loading: false, loading: false,
map: null as Nullable<Map>, map: null as Nullable<Map>,
mappedPoints: [] as Point[],
mapView: null as Nullable<View>, mapView: null as Nullable<View>,
pointsLayer: null as Nullable<VectorLayer>, pointsLayer: null as Nullable<VectorLayer>,
popup: null as Nullable<Overlay>, 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: { methods: {
async fetch(): Promise<GPSPoint[]> { async fetch(): Promise<GPSPoint[]> {
this.loading = true this.loading = true
@ -137,11 +159,10 @@ export default {
this.locationQuery = prevPageQuery this.locationQuery = prevPageQuery
}, },
createMap(gpsPoints: GPSPoint[]): Map { createMap(): Map {
this.mappedPoints = this.toMappedPoints(gpsPoints) this.pointsLayer = this.createPointsLayer(Object.values(this.mappedPoints) as Point[])
this.pointsLayer = this.createPointsLayer(this.mappedPoints as Point[]) this.routesLayer = this.createRoutesLayer(Object.values(this.mappedPoints) as Point[])
this.routesLayer = this.createRoutesLayer(this.mappedPoints as Point[]) this.mapView = this.mapView || this.createMapView(this.gpsPoints)
this.mapView = this.mapView || this.createMapView(gpsPoints)
const map = new Map({ const map = new Map({
target: 'map', target: 'map',
layers: [ layers: [
@ -254,14 +275,13 @@ export default {
this.hasPrevPage = gpsPoints.length > 1 this.hasPrevPage = gpsPoints.length > 1
} }
this.mappedPoints = this.toMappedPoints(this.gpsPoints)
if (this.mapView) { if (this.mapView) {
// @ts-ignore // @ts-ignore
this.refreshMapView(this.mapView, this.gpsPoints) this.refreshMapView(this.mapView, this.gpsPoints)
// @ts-ignore // @ts-ignore
this.refreshPointsLayer(this.pointsLayer, this.mappedPoints) this.refreshPointsLayer(this.pointsLayer, Object.values(this.mappedPoints))
// @ts-ignore // @ts-ignore
this.refreshRoutesLayer(this.routesLayer, this.mappedPoints) this.refreshRoutesLayer(this.routesLayer, Object.values(this.mappedPoints))
} }
}, },
deep: true, deep: true,
@ -271,7 +291,7 @@ export default {
async mounted() { async mounted() {
this.initQuery() this.initQuery()
this.gpsPoints = await this.fetch() this.gpsPoints = await this.fetch()
this.map = this.createMap(this.gpsPoints) this.map = this.createMap()
}, },
} }
</script> </script>
@ -280,17 +300,33 @@ export default {
@use "@/styles/common.scss" as *; @use "@/styles/common.scss" as *;
@import "ol/ol.css"; @import "ol/ol.css";
$timeline-height: 7.5em;
html, html,
body { body {
margin: 0; margin: 0;
height: 100%; height: 100%;
} }
main {
width: 100%;
height: 100%;
}
.map-body {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
}
#map { #map {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
height: calc(100% - #{$timeline-height});
.controls { .controls {
position: absolute; 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 { @keyframes unroll {
from { from {
transform: translateY(7.5em); transform: translateY(7.5em);

View file

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

View file

@ -16,16 +16,38 @@ const maxZoom = 18
useGeographic() 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 { export default {
mixins: [Geo, Units], mixins: [Geo, Units],
data() { data() {
return { return {
metersTolerance: 20, metersTolerance: 20,
highlightedPointId: null,
highlightedFeature: null,
} }
}, },
methods: { methods: {
groupPoints(points: GPSPoint[]) { groupPoints(points: GPSPoint[]): GPSPoint[] {
if (!points.length) { if (!points.length) {
return [] return []
} }
@ -93,8 +115,12 @@ export default {
}, },
toMappedPoints(gpsPoints: GPSPoint[]): Point[] { toMappedPoints(gpsPoints: GPSPoint[]): Point[] {
return this.groupPoints(gpsPoints).map( return gpsPoints.map(
(gps: GPSPoint) => new Point([gps.longitude, gps.latitude]) (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> </script>

View file

@ -13,3 +13,12 @@ export default {
<Map /> <Map />
</main> </main>
</template> </template>
<style lang="scss">
main {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
</style>