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 }}
+
+
+
+
+
+
+ `,
+ 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;
+}