mirror of
https://github.com/owntracks/frontend.git
synced 2026-08-25 20:47:16 +00:00
Add existing project files
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# OwnTracks UI
|
||||
|
||||
> A modern web interface for OwnTracks
|
||||
|
||||
## Introduction
|
||||
|
||||
This is a WebInterface for OwnTracks, intended to replace the various web pages shipping with the recorder. OwnTracks UI uses Vue.js under the hood.
|
||||
|
||||
*This is not an official OwnTracks project.*
|
||||
|
||||
## Installation
|
||||
|
||||
Clone the repository and copy `index.html` and the `static/` directory to your server's webroot. The API is expected to be reachable under the same domain as the web interface.
|
||||
|
||||
## Features
|
||||
|
||||
- Enable or disable multiple layers:
|
||||
|
||||
- Last known (i.e. live) locations:
|
||||
- Accuracy visualization (circle)
|
||||
- Device friendly name and icon
|
||||
- Detailed information (if available): time, lat, lon, height, battery and speed
|
||||
|
||||
- Location history (data points, line or both)
|
||||
- Location heatmap
|
||||
- Button to quickly fit all shown objects on the map into view
|
||||
|
||||
- Display data in a specific date range
|
||||
- Filter by user and device
|
||||
- Customizable:
|
||||
|
||||
- UI color
|
||||
- Default start and end date
|
||||
- Map:
|
||||
|
||||
- Tile server
|
||||
- Max zoom
|
||||
- Default position and zoom
|
||||
- Heatmap colors, radius and blur
|
||||
|
||||
## ToDo
|
||||
|
||||
- Node.js based development workflow:
|
||||
|
||||
- Webpack
|
||||
- Vue SFCs
|
||||
- Sass
|
||||
- Dependency management with yarn instead of a local copy or unpkg.com
|
||||
|
||||
- Docker support
|
||||
- Download data for selected date range, user and device as JSON
|
||||
|
||||
## Contributing
|
||||
|
||||
Please feel free to open an issue and discuss your ideas and report bugs. If you think you can help out with something, open a PR!
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, height=device-height, user-scalable=no, initial-scale=1.0" />
|
||||
<title>OwnTracks</title>
|
||||
<link href="static/style.css" rel="stylesheet">
|
||||
<link href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css?family=Noto+Sans" rel="stylesheet">
|
||||
<link href="https://unpkg.com/@mdi/font@3.5.95/css/materialdesignicons.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-item">
|
||||
<button
|
||||
class="button button-outline"
|
||||
title="Automatically center the map view and zoom in to relevant data"
|
||||
@click="centerView"
|
||||
>
|
||||
Center View
|
||||
</button>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<span class="mdi mdi-24px mdi-layers"></span>
|
||||
<div class="dropdown">
|
||||
<button class="dropdown-button button" title="Show/hide layers">
|
||||
Layer Settings
|
||||
</button>
|
||||
<div class="dropdown-body">
|
||||
<label tabindex="0">
|
||||
<input type="checkbox" v-model="showLastLocations">
|
||||
Show last known locations
|
||||
</label>
|
||||
<label tabindex="0">
|
||||
<input type="checkbox" v-model="showLocationHistoryLine">
|
||||
Show location history (line)
|
||||
</label>
|
||||
<label tabindex="0">
|
||||
<input type="checkbox" v-model="showLocationHistoryPoints">
|
||||
Show location history (points)
|
||||
</label>
|
||||
<label tabindex="0">
|
||||
<input type="checkbox" v-model="showLocationHeatmap">
|
||||
Show location heatmap
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<span class="mdi mdi-24px mdi-calendar-range"></span>
|
||||
<vuejs-datepicker
|
||||
v-model="startDate"
|
||||
:use-utc="true"
|
||||
:disabled-dates="startDateDisabledDates"
|
||||
title="Select start date"
|
||||
></vuejs-datepicker>
|
||||
to
|
||||
<vuejs-datepicker
|
||||
v-model="endDate"
|
||||
:use-utc="true"
|
||||
:disabled-dates="endDateDisabledDates"
|
||||
title="Select end date"
|
||||
></vuejs-datepicker>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<span class="mdi mdi-24px mdi-account"></span>
|
||||
<select v-model="selectedUser" class="dropdown-button button" title="Select user">
|
||||
<option value="">
|
||||
Show All
|
||||
</option>
|
||||
<option v-for="user in users" :value="user">
|
||||
{{ user }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedUser" class="nav-item">
|
||||
<span class="mdi mdi-24px mdi-cellphone-link"></span>
|
||||
<select v-model="selectedDevice" class="dropdown-button button" title="Select device">
|
||||
<option value="">
|
||||
Show All
|
||||
</option>
|
||||
<option v-for="device in devices[selectedUser]" :value="device">
|
||||
{{ device }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</nav>
|
||||
<nav class="nav-shrink">
|
||||
<div class="nav-item">
|
||||
<button
|
||||
class="button button-flat button-icon"
|
||||
title="Download raw data"
|
||||
@click="showDownloadModal = !showDownloadModal"
|
||||
>
|
||||
<span class="mdi mdi-24px mdi-download"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<button
|
||||
class="button button-flat button-icon"
|
||||
title="Information"
|
||||
@click="showInformationModal = !showInformationModal"
|
||||
>
|
||||
<span class="mdi mdi-24px mdi-information-outline"></span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<l-map ref="map" :zoom="map.zoom" :center="map.center">
|
||||
<l-tile-layer
|
||||
:url="map.url"
|
||||
:attribution="map.attribution"
|
||||
:options="{maxNativeZoom: map.maxNativeZoom, maxZoom: map.maxZoom}"
|
||||
></l-tile-layer>
|
||||
|
||||
<l-circle
|
||||
v-if="showLastLocations"
|
||||
v-for="l in lastLocations"
|
||||
:key="`${l.topic}-circle`"
|
||||
:lat-lng="{lat: l.lat, lng: l.lon}"
|
||||
:radius="l.acc"
|
||||
:color="map.circle.color"
|
||||
:fill-color="map.circle.fillColor"
|
||||
:fill-opacity="map.circle.fillOpacity"
|
||||
></l-circle>
|
||||
|
||||
<l-marker
|
||||
v-if="showLastLocations"
|
||||
v-for="l in lastLocations"
|
||||
:key="`${l.topic}-marker`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
>
|
||||
<location-popup
|
||||
:user="l.username"
|
||||
:device="l.device"
|
||||
:name="l.name"
|
||||
:face="l.face"
|
||||
:timestamp="l.tst"
|
||||
:lat="l.lat"
|
||||
:lon="l.lon"
|
||||
:alt="l.alt"
|
||||
:battery="l.batt"
|
||||
:speed="l.vel"
|
||||
></location-popup>
|
||||
</l-marker>
|
||||
|
||||
<l-polyline
|
||||
v-if="showLocationHistoryLine"
|
||||
:lat-lngs="locationHistoryLatLngs"
|
||||
:color="map.polyline.color"
|
||||
:fill-color="map.polyline.fillColor"
|
||||
></l-polyline>
|
||||
|
||||
<template v-if="showLocationHistoryPoints">
|
||||
<template v-for="(userDevices, user) in locationHistory">
|
||||
<template v-for="(deviceLocations, device) in userDevices">
|
||||
<l-circle-marker
|
||||
v-for="(l, n) in deviceLocations"
|
||||
:key="`${user}-${device}-${n}`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
:radius="map.circleMarker.radius"
|
||||
:color="map.circleMarker.color"
|
||||
:fill-color="map.circleMarker.fillColor"
|
||||
:fill-opacity="map.circleMarker.fillOpacity"
|
||||
>
|
||||
<location-popup
|
||||
:user="user"
|
||||
:device="device"
|
||||
:timestamp="l.tst"
|
||||
:lat="l.lat"
|
||||
:lon="l.lon"
|
||||
:alt="l.alt"
|
||||
:battery="l.batt"
|
||||
:speed="l.vel"
|
||||
></location-popup>
|
||||
</l-circle-marker>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="showLocationHeatmap">
|
||||
<l-heatmap
|
||||
v-if="locationHistoryLatLngs.length"
|
||||
:lat-lng="locationHistoryLatLngs"
|
||||
:max="map.heatmap.max"
|
||||
:radius="map.heatmap.radius"
|
||||
:blur="map.heatmap.blur"
|
||||
:gradient="map.heatmap.gradient"
|
||||
></l-heatmap>
|
||||
</template>
|
||||
|
||||
</l-map>
|
||||
</main>
|
||||
|
||||
<modal :visible="showDownloadModal" @close="showDownloadModal = false">
|
||||
Not implemented.
|
||||
</modal>
|
||||
<modal :visible="showInformationModal" @close="showInformationModal = false">
|
||||
<b>OwnTracks {{ information.ownTracks.version }}</b>
|
||||
<ul>
|
||||
<li><a :href="information.ownTracksUi.sourceCodeUrl">OwnTracks UI Source Code</a></li>
|
||||
<li><a :href="information.ownTracks.documentationUrl">OwnTracks Recorder Documentation</a></li>
|
||||
<li><a :href="information.ownTracks.sourceCodeUrl">OwnTracks Recorder Source Code</a></li>
|
||||
<li><a :href="information.ownTracks.twitterUrl">OwnTracks Twitter</a></li>
|
||||
</ul>
|
||||
</modal>
|
||||
</div>
|
||||
<script src="https://unpkg.com/vue@2.5.22/dist/vue.min.js"></script>
|
||||
<script src="https://unpkg.com/vuejs-datepicker@1.5.4/dist/vuejs-datepicker.min.js"></script>
|
||||
<script src="https://unpkg.com/leaflet@1.4.0/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/vue2-leaflet@1.2.3/dist/vue2-leaflet.min.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
<script src="https://unpkg.com/deepmerge@3.2.0/dist/umd.js"></script>
|
||||
<script src="static/components/vue-leaflet-heatmap.js"></script>
|
||||
<script src="static/components/location-popup.js"></script>
|
||||
<script src="static/components/modal.js"></script>
|
||||
<script src="static/config/default.js"></script>
|
||||
<script src="static/config/custom.js"></script>
|
||||
<script src="static/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
(() => {
|
||||
const props = {
|
||||
user: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
device: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
face: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
timestamp: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
lat: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
lon: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
alt: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
battery: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
speed: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
const { LPopup } = Vue2Leaflet;
|
||||
Vue.component('location-popup', {
|
||||
template: `
|
||||
<l-popup>
|
||||
<img v-if="face" class="location-popup-face" :src="faceImageDataURI">
|
||||
<b v-if="name">{{ name }}</b>
|
||||
<b v-else>{{ user }}/{{ device }}</b>
|
||||
<div class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-calendar-clock"></span> {{ new Date(timestamp * 1000).toLocaleString() }}
|
||||
</div>
|
||||
<div class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-crosshairs-gps"></span> {{ lat }}, {{ lon }}, {{ alt }}m
|
||||
</div class="location-popup-detail">
|
||||
<div v-if="address" class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-map-marker"></span> {{ address }}
|
||||
</div>
|
||||
<div v-if="typeof battery === 'number'" class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-battery"></span> {{ battery }} %
|
||||
</div>
|
||||
<div v-if="typeof battery === 'number'" class="location-popup-detail">
|
||||
<span class="mdi mdi-16px mdi-speedometer"></span> {{ speed }} km/h
|
||||
</div>
|
||||
</l-popup>
|
||||
`,
|
||||
components: { LPopup },
|
||||
props,
|
||||
computed: {
|
||||
faceImageDataURI() {
|
||||
return `data:image/png;base64,${this.face}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,18 @@
|
||||
Vue.component('modal', {
|
||||
template: `
|
||||
<div class="modal" v-show="visible" @click.self="$emit('close')">
|
||||
<div class="modal-container">
|
||||
<button class="modal-close-button" title="Close" @click="$emit('close')">
|
||||
×
|
||||
</button>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
(() => {
|
||||
const capitalizeFirstLetter = (string) => {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
const propsBinder = (vueElement, leafletElement, props) => {
|
||||
for (const key in props) {
|
||||
const setMethodName = 'set' + capitalizeFirstLetter(key);
|
||||
const deepValue = (props[key].type === Object) ||
|
||||
(props[key].type === Array) ||
|
||||
(Array.isArray(props[key].type));
|
||||
if (props[key].custom && vueElement[setMethodName]) {
|
||||
vueElement.$watch(key, (newVal, oldVal) => {
|
||||
vueElement[setMethodName](newVal, oldVal);
|
||||
}, {
|
||||
deep: deepValue
|
||||
});
|
||||
} else if (setMethodName === 'setOptions') {
|
||||
vueElement.$watch(key, (newVal, oldVal) => {
|
||||
L.setOptions(leafletElement, newVal);
|
||||
}, {
|
||||
deep: deepValue
|
||||
});
|
||||
} else if (leafletElement[setMethodName]) {
|
||||
vueElement.$watch(key, (newVal, oldVal) => {
|
||||
leafletElement[setMethodName](newVal);
|
||||
}, {
|
||||
deep: deepValue
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { findRealParent, L } = Vue2Leaflet;
|
||||
const props = {
|
||||
latLng: {
|
||||
type: Array,
|
||||
custom: false,
|
||||
default: () => []
|
||||
},
|
||||
minOpacity: {
|
||||
type: Number,
|
||||
custom: true,
|
||||
default: 0.05
|
||||
},
|
||||
maxZoom: {
|
||||
type: Number,
|
||||
custom: true,
|
||||
default: 18
|
||||
},
|
||||
radius: {
|
||||
type: Number,
|
||||
custom: true,
|
||||
default: 25
|
||||
},
|
||||
blur: {
|
||||
type: Number,
|
||||
custom: true,
|
||||
default: 15
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
custom: true,
|
||||
default: 1.0
|
||||
},
|
||||
gradient: {
|
||||
type: Object,
|
||||
custom: true,
|
||||
default: () => ({
|
||||
0.4: 'blue',
|
||||
0.6: 'cyan',
|
||||
0.7: 'lime',
|
||||
0.8: 'yellow',
|
||||
1.0: 'red'
|
||||
})
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
custom: true,
|
||||
default: true
|
||||
}
|
||||
};
|
||||
|
||||
Vue.component('l-heatmap', {
|
||||
props,
|
||||
template: '<div></div>',
|
||||
mounted() {
|
||||
const options = {};
|
||||
if (this.minOpacity) {
|
||||
options.minOpacity = this.minOpacity;
|
||||
}
|
||||
if (this.maxZoom) {
|
||||
options.maxZoom = this.maxZoom;
|
||||
}
|
||||
if (this.radius) {
|
||||
options.radius = this.radius;
|
||||
}
|
||||
if (this.blur) {
|
||||
options.blur = this.blur;
|
||||
}
|
||||
if (this.max) {
|
||||
options.max = this.max;
|
||||
}
|
||||
if (this.gradient) {
|
||||
options.gradient = this.gradient;
|
||||
}
|
||||
this.mapObject = L.heatLayer(this.latLng, options);
|
||||
L.DomEvent.on(this.mapObject, this.$listeners);
|
||||
propsBinder(this, this.mapObject, props);
|
||||
|
||||
this.$watch('latLng', (newVal, _) => {
|
||||
this.mapObject.setLatLngs(newVal);
|
||||
}, { deep: true });
|
||||
this.parentContainer = findRealParent(this.$parent);
|
||||
this.parentContainer.addLayer(this, !this.visible);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.parentContainer.removeLayer(this);
|
||||
},
|
||||
methods: {
|
||||
setMinOpacity(newVal) {
|
||||
this.mapObject.setOptions({ minOpacity: newVal });
|
||||
},
|
||||
setMaxZoom(newVal) {
|
||||
this.mapObject.setOptions({ maxZoom: newVal });
|
||||
},
|
||||
setRadius(newVal) {
|
||||
this.mapObject.setOptions({ radius: newVal });
|
||||
},
|
||||
setBlur(newVal) {
|
||||
this.mapObject.setOptions({ blur: newVal });
|
||||
},
|
||||
setMax(newVal) {
|
||||
this.mapObject.setOptions({ max: newVal });
|
||||
},
|
||||
setGradient(newVal) {
|
||||
this.mapObject.setOptions({ gradient: newVal });
|
||||
},
|
||||
setVisible(newVal, oldVal) {
|
||||
if (newVal === oldVal) return;
|
||||
if (newVal) {
|
||||
this.parentContainer.addLayer(this);
|
||||
} else {
|
||||
this.parentContainer.removeLayer(this);
|
||||
}
|
||||
},
|
||||
addLatLng(value) {
|
||||
this.mapObject.addLatLng(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,2 @@
|
||||
// Here you can overwite values from default.js
|
||||
window.config = {};
|
||||
@@ -0,0 +1,27 @@
|
||||
(() => {
|
||||
const endDate = new Date();
|
||||
endDate.setUTCHours(0);
|
||||
endDate.setUTCMinutes(0);
|
||||
endDate.setUTCSeconds(0);
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setUTCMonth(startDate.getMonth()-1);
|
||||
window.defaultConfig = {
|
||||
accentColor: '#3388ff',
|
||||
startDate,
|
||||
endDate,
|
||||
map: {
|
||||
center: L.latLng(0, 0),
|
||||
zoom: 19,
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 21,
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
|
||||
heatmap: {
|
||||
max: 20,
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
gradient: null, // https://github.com/mourner/simpleheat/blob/gh-pages/simpleheat.js#L22
|
||||
},
|
||||
},
|
||||
};
|
||||
})();
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
(() => {
|
||||
const { LMap, LTileLayer, LMarker, LCircleMarker, LCircle, LPolyline } = Vue2Leaflet;
|
||||
const config = deepmerge(window.defaultConfig, window.config);
|
||||
new Vue({
|
||||
el: '#app',
|
||||
components: { vuejsDatepicker, LMap, LTileLayer, LMarker, LCircleMarker, LPolyline, LCircle },
|
||||
data: {
|
||||
users: [],
|
||||
devices: {},
|
||||
lastLocations: [],
|
||||
locationHistory: {},
|
||||
showLastLocations: true,
|
||||
showLocationHistoryPoints: false,
|
||||
showLocationHistoryLine: false,
|
||||
showLocationHeatmap: false,
|
||||
selectedUser: '',
|
||||
selectedDevice: '',
|
||||
startDate: config.startDate,
|
||||
endDate: config.endDate,
|
||||
showDownloadModal: false,
|
||||
showInformationModal: false,
|
||||
map: {
|
||||
center: config.map.center,
|
||||
zoom: config.map.zoom,
|
||||
maxNativeZoom: config.map.maxNativeZoom,
|
||||
maxZoom: config.map.maxZoom,
|
||||
url: config.map.url,
|
||||
attribution: config.map.attribution,
|
||||
polyline: {
|
||||
color: config.accentColor,
|
||||
fillColor: 'transparent',
|
||||
},
|
||||
circle: {
|
||||
color: config.accentColor,
|
||||
fillColor: config.accentColor,
|
||||
fillOpacity: 0.2,
|
||||
},
|
||||
circleMarker: {
|
||||
radius: 4,
|
||||
color: config.accentColor,
|
||||
fillColor: '#fff',
|
||||
fillOpacity: 1,
|
||||
},
|
||||
heatmap: {
|
||||
max: config.map.heatmap.max,
|
||||
radius: config.map.heatmap.radius,
|
||||
blur: config.map.heatmap.radius,
|
||||
gradient: config.map.heatmap.gradient,
|
||||
},
|
||||
},
|
||||
information: {
|
||||
ownTracks: {
|
||||
version: '',
|
||||
documentationUrl: 'https://owntracks.org/booklet/',
|
||||
sourceCodeUrl: 'https://github.com/owntracks/recorder',
|
||||
twitterUrl: 'https://twitter.com/OwnTracks',
|
||||
},
|
||||
ownTracksUi: {
|
||||
sourceCodeUrl: 'https://github.com/linusg/owntracks-ui',
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedUser: async function () {
|
||||
this.selectedDevice = '';
|
||||
this.lastLocations = await this.getLastLocations();
|
||||
this.locationHistory = await this.getLocationHistory();
|
||||
},
|
||||
selectedDevice: async function () {
|
||||
this.lastLocations = await this.getLastLocations();
|
||||
this.locationHistory = await this.getLocationHistory();
|
||||
},
|
||||
startDate: async function () {
|
||||
this.locationHistory = await this.getLocationHistory();
|
||||
},
|
||||
endDate: async function () {
|
||||
this.locationHistory = await this.getLocationHistory();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
locationHistoryLatLngs() {
|
||||
const latLngs = [];
|
||||
Object.keys(this.locationHistory).forEach((user) => {
|
||||
Object.keys(this.locationHistory[user]).forEach((device) => {
|
||||
this.locationHistory[user][device].forEach((l) => {
|
||||
latLngs.push(L.latLng(l.lat, l.lon));
|
||||
});
|
||||
});
|
||||
});
|
||||
return latLngs;
|
||||
},
|
||||
startDateDisabledDates() {
|
||||
return {
|
||||
customPredictor: (date) => (date > this.endDate) || (date > new Date())
|
||||
};
|
||||
},
|
||||
endDateDisabledDates() {
|
||||
return {
|
||||
customPredictor: (date) => (date < this.startDate) || (date > new Date())
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
init: async function () {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--color-accent', config.accentColor);
|
||||
this.users = await this.getUsers();
|
||||
this.devices = await this.getDevices();
|
||||
this.lastLocations = await this.getLastLocations();
|
||||
this.locationHistory = await this.getLocationHistory();
|
||||
this.centerView();
|
||||
await this.connectWebsocket();
|
||||
this.information.ownTracks.version = await this.getVersion();
|
||||
},
|
||||
connectWebsocket: async function () {
|
||||
const wsUrl = `${document.location.protocol.replace('http', 'ws')}//${document.location.host}/ws/last`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
console.log(`[WS] Connecting to ${wsUrl}...`);
|
||||
ws.onopen = (e) => {
|
||||
console.log('[WS] Connected');
|
||||
ws.send('LAST');
|
||||
};
|
||||
ws.onclose = () => {
|
||||
console.log('[WS] Disconnected. Reconnecting in one second...')
|
||||
setTimeout(this.connectWebsocket, 1000);
|
||||
};
|
||||
ws.onmessage = async (msg) => {
|
||||
if (msg.data) {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data._type === 'location') {
|
||||
console.log('[WS] Location update received');
|
||||
this.lastLocations = await this.getLastLocations();
|
||||
this.locationHistory = await this.getLocationHistory();
|
||||
}
|
||||
} catch (err) {}
|
||||
} else {
|
||||
console.log('[WS] Ping');
|
||||
}
|
||||
};
|
||||
},
|
||||
getVersion: async function () {
|
||||
const response = await fetch('/api/0/version');
|
||||
const json = await response.json();
|
||||
const version = json.version;
|
||||
return version;
|
||||
},
|
||||
getUsers: async function () {
|
||||
const response = await fetch('/api/0/list');
|
||||
const json = await response.json();
|
||||
const users = json.results;
|
||||
return users;
|
||||
},
|
||||
getDevices: async function () {
|
||||
const devices = {};
|
||||
await Promise.all(this.users.map(async (user) => {
|
||||
const response = await fetch(`/api/0/list?user=${user}`);
|
||||
const json = await response.json();
|
||||
const userDevices = json.results;
|
||||
devices[user] = userDevices;
|
||||
}));
|
||||
return devices;
|
||||
},
|
||||
getLastLocations: async function () {
|
||||
let url = '/api/0/last';
|
||||
if (this.selectedUser !== '') {
|
||||
url += `?&user=${this.selectedUser}`;
|
||||
if (this.selectedDevice !== '') {
|
||||
url += `&device=${this.selectedDevice}`;
|
||||
}
|
||||
}
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
return json;
|
||||
},
|
||||
getLocationHistory: async function () {
|
||||
let users;
|
||||
let devices;
|
||||
if (this.selectedUser === '') {
|
||||
users = this.users;
|
||||
devices = { ...this.devices };
|
||||
} else {
|
||||
users = [this.selectedUser];
|
||||
if (this.selectedDevice === '') {
|
||||
devices = { [this.selectedUser]: this.devices[this.selectedUser] };
|
||||
} else {
|
||||
devices = { [this.selectedUser]: [this.selectedDevice] };
|
||||
}
|
||||
}
|
||||
const locations = {};
|
||||
await Promise.all(users.map(async (user) => {
|
||||
locations[user] = {};
|
||||
await Promise.all(devices[user].map(async (device) => {
|
||||
const startDateString = `${this.startDate.toISOString().split('T')[0]}T00:00:00`;
|
||||
const endDateString = `${this.endDate.toISOString().split('T')[0]}T23:59:59`;
|
||||
const url = `/api/0/locations?from=${startDateString}&to=${endDateString}&format=json&user=${user}&device=${device}`;
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
const userDeviceLocations = json.data;
|
||||
locations[user][device] = userDeviceLocations;
|
||||
}));
|
||||
}));
|
||||
return locations;
|
||||
},
|
||||
centerView() {
|
||||
if ((this.showLocationHistoryPoints || this.showLocationHistoryLine || this.showLocationHeatmap) && this.locationHistoryLatLngs.length > 0) {
|
||||
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(this.locationHistoryLatLngs));
|
||||
} else if (this.showLastLocations && this.lastLocations.length > 0) {
|
||||
const locations = this.lastLocations.map((l) => L.latLng(l.lat, l.lon));
|
||||
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(locations), {maxZoom: this.map.maxNativeZoom});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,263 @@
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-text: #333;
|
||||
--color-background: #fff;
|
||||
--color-accent: #3388ff;
|
||||
--color-accent-text: #fff;
|
||||
--drop-shadow: drop-shadow(0 10px 10px rgb(0, 0, 0, 0.2));
|
||||
--dropdown-arrow: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2225%22%20height%3D%2210%22%3E%3Cpath%20fill%3D%22%23333%22%20fill-opacity%3D%221%22%20stroke%3D%22none%22%20d%3D%22M0%2C0%20L0%2C0%20L1%2C0%20L1%2C6%20L7%2C6%20L7%2C7%20L0%2C7%20z%22%20transform%3D%22rotate(-45%205%200)%22%20%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Noto Sans", sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: inside;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#app > header {
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
color: var(--color-accent-text);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
#app > header > nav {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#app > header > nav:not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
#app > header > nav.nav-shrink {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
#app > header > nav .nav-item {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#app > header > nav .nav-item:not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
#app > main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.button,
|
||||
.vdp-datepicker input {
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border: 1px solid var(--color-background);
|
||||
color: var(--color-accent-text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.button-flat {
|
||||
color: var(--color-accent-text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-button,
|
||||
.vdp-datepicker input {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: var(--dropdown-arrow);
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: right;
|
||||
background-position-y: center;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.dropdown-body {
|
||||
display: none;
|
||||
position: absolute;
|
||||
margin-top: 12px;
|
||||
padding: 8px 0;
|
||||
border-radius: 3px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
filter: var(--drop-shadow);
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.dropdown-body::before,
|
||||
.vdp-datepicker .vdp-datepicker__calendar::before {
|
||||
content: "";
|
||||
border-left: 10px solid transparent;
|
||||
border-right: 10px solid transparent;
|
||||
border-top: 10px solid transparent;
|
||||
border-bottom: 10px solid var(--color-background);
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.dropdown:focus-within .dropdown-body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-body label {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
.dropdown-body label:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-body label input[type=checkbox] {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
filter: var(--drop-shadow);
|
||||
z-index: 4000;
|
||||
}
|
||||
|
||||
.modal .modal-container {
|
||||
min-width: 300px;
|
||||
padding: 20px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.modal .modal-container .modal-close-button {
|
||||
display: block;
|
||||
border: none;
|
||||
float: right;
|
||||
font-size: 24px;
|
||||
line-height: 16px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-popup-face {
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-background);
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.location-popup-detail {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-popup {
|
||||
filter: var(--drop-shadow);
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-popup .leaflet-popup-content-wrapper {
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-popup a.leaflet-popup-close-button {
|
||||
padding: 5px 5px 0 0;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip-container .leaflet-popup-tip {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vdp-datepicker {
|
||||
position: static !important;
|
||||
display: inline-block;
|
||||
white-space: initial;
|
||||
overflow: initial;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.vdp-datepicker input {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.vdp-datepicker .vdp-datepicker__calendar {
|
||||
color: var(--color-text);
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
z-index: 4000;
|
||||
margin-top: 12px;
|
||||
filter: var(--drop-shadow);
|
||||
}
|
||||
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).day:hover,
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).month:hover,
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).year:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell.selected,
|
||||
.vdp-datepicker .vdp-datepicker__calendar .cell.selected:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-text);
|
||||
}
|
||||
|
||||
header .mdi {
|
||||
position: relative;
|
||||
top: 5px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
header .button .mdi {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.mdi-16px.mdi-set,
|
||||
.mdi-16px.mdi::before {
|
||||
font-size: 16px;
|
||||
}
|
||||
Reference in New Issue
Block a user