From d34076e11ff0ec615c107f01fb1e7e89c46d887d Mon Sep 17 00:00:00 2001 From: Linus Groh Date: Fri, 1 Mar 2019 22:26:10 +0100 Subject: [PATCH] Add existing project files --- README.md | 55 +++++ index.html | 224 +++++++++++++++++++ static/components/location-popup.js | 80 +++++++ static/components/modal.js | 18 ++ static/components/vue-leaflet-heatmap.js | 152 +++++++++++++ static/config/custom.js | 2 + static/config/default.js | 27 +++ static/main.js | 218 +++++++++++++++++++ static/style.css | 263 +++++++++++++++++++++++ 9 files changed, 1039 insertions(+) create mode 100644 README.md create mode 100644 index.html create mode 100644 static/components/location-popup.js create mode 100644 static/components/modal.js create mode 100644 static/components/vue-leaflet-heatmap.js create mode 100644 static/config/custom.js create mode 100644 static/config/default.js create mode 100644 static/main.js create mode 100644 static/style.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd4ba37 --- /dev/null +++ b/README.md @@ -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! diff --git a/index.html b/index.html new file mode 100644 index 0000000..0109217 --- /dev/null +++ b/index.html @@ -0,0 +1,224 @@ + + + + + + OwnTracks + + + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + +
+ + + Not implemented. + + + OwnTracks {{ information.ownTracks.version }} + + +
+ + + + + + + + + + + + + + diff --git a/static/components/location-popup.js b/static/components/location-popup.js new file mode 100644 index 0000000..5abaa90 --- /dev/null +++ b/static/components/location-popup.js @@ -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: ` + + + {{ name }} + {{ user }}/{{ device }} +
+ {{ new Date(timestamp * 1000).toLocaleString() }} +
+
+ {{ lat }}, {{ lon }}, {{ alt }}m +
+
+ {{ address }} +
+
+ {{ battery }} % +
+
+ {{ speed }} km/h +
+
+ `, + components: { LPopup }, + props, + computed: { + faceImageDataURI() { + return `data:image/png;base64,${this.face}`; + }, + }, + }); +})(); diff --git a/static/components/modal.js b/static/components/modal.js new file mode 100644 index 0000000..06eb3e1 --- /dev/null +++ b/static/components/modal.js @@ -0,0 +1,18 @@ +Vue.component('modal', { + template: ` + + `, + props: { + visible: { + type: Boolean, + default: false, + }, + }, +}); diff --git a/static/components/vue-leaflet-heatmap.js b/static/components/vue-leaflet-heatmap.js new file mode 100644 index 0000000..b158d40 --- /dev/null +++ b/static/components/vue-leaflet-heatmap.js @@ -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: '
', + 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); + } + } + }); +})(); diff --git a/static/config/custom.js b/static/config/custom.js new file mode 100644 index 0000000..6a21ab8 --- /dev/null +++ b/static/config/custom.js @@ -0,0 +1,2 @@ +// Here you can overwite values from default.js +window.config = {}; diff --git a/static/config/default.js b/static/config/default.js new file mode 100644 index 0000000..a68b442 --- /dev/null +++ b/static/config/default.js @@ -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: '© OpenStreetMap contributors', + heatmap: { + max: 20, + radius: 25, + blur: 15, + gradient: null, // https://github.com/mourner/simpleheat/blob/gh-pages/simpleheat.js#L22 + }, + }, + }; +})(); diff --git a/static/main.js b/static/main.js new file mode 100644 index 0000000..98a9a3e --- /dev/null +++ b/static/main.js @@ -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(); + }, + }); +})(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b97f5df --- /dev/null +++ b/static/style.css @@ -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; +}