mirror of
https://github.com/owntracks/frontend.git
synced 2026-02-13 20:59:50 +00:00
Publish 2.0.0-alpha source
This commit is contained in:
3
.browserslistrc
Normal file
3
.browserslistrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not ie > 0
|
||||||
22
.eslintrc.js
Normal file
22
.eslintrc.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true
|
||||||
|
},
|
||||||
|
extends: ["plugin:vue/essential", "@vue/prettier"],
|
||||||
|
rules: {
|
||||||
|
"no-console": process.env.NODE_ENV === "production" ? "error" : "off",
|
||||||
|
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off",
|
||||||
|
"prettier/prettier": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
trailingComma: "es5",
|
||||||
|
printWidth: 80,
|
||||||
|
htmlWhitespaceSensitivity: "ignore",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
parser: "babel-eslint"
|
||||||
|
}
|
||||||
|
};
|
||||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -1,3 +1,26 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw*
|
||||||
|
|
||||||
|
public/config/config.js
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -1,6 +1,6 @@
|
|||||||
# OwnTracks UI
|
# OwnTracks UI
|
||||||
|
|
||||||
> A modern web interface for OwnTracks made with Vue.js
|
> Web interface for OwnTracks
|
||||||
|
|
||||||
[](https://hub.docker.com/r/owntracks/frontend)
|
[](https://hub.docker.com/r/owntracks/frontend)
|
||||||
|
|
||||||
@@ -10,17 +10,32 @@
|
|||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
This is a web interface for [OwnTracks](https://github.com/owntracks/recorder), intended to replace the various web pages shipping with the recorder. OwnTracks UI uses Vue.js under the hood.
|
This is a web interface for [OwnTracks](https://github.com/owntracks/recorder) built as
|
||||||
|
a Vue.js single page application. The recorder itself already ships with some basic web
|
||||||
|
pages, this is a more advanced interface with more functionality, all in one place.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Manual install
|
### Manually
|
||||||
|
|
||||||
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.
|
For development:
|
||||||
|
|
||||||
|
- Run `yarn install` to install dependencies
|
||||||
|
- Run `yarn serve` to compile for development and start the hot-reload server
|
||||||
|
- Run `yarn lint` to lint and fix files
|
||||||
|
|
||||||
|
To deploy:
|
||||||
|
|
||||||
|
- Run `yarn install --production` to install dependencies
|
||||||
|
- Run `yarn build` to compile and minify for production
|
||||||
|
- Copy the content of the `dist/` directory to your webroot
|
||||||
|
|
||||||
|
The API is expected to be reachable under the same domain as the web interface.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
You can launch directly via Docker run like this:
|
You can launch directly via Docker run like this:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ docker run -d -p 80:80 -e SERVER_HOST=otrecorder-host -e SERVER_PORT=otrecorder-port owntracks/frontend
|
$ docker run -d -p 80:80 -e SERVER_HOST=otrecorder-host -e SERVER_PORT=otrecorder-port owntracks/frontend
|
||||||
```
|
```
|
||||||
@@ -44,30 +59,26 @@ services:
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Enable or disable multiple layers:
|
- Enable or disable multiple layers:
|
||||||
|
|
||||||
- Last known (i.e. live) locations:
|
- Last known (i.e. live) locations:
|
||||||
|
|
||||||
- Accuracy visualization (circle)
|
- Accuracy visualization (circle)
|
||||||
- Device friendly name and icon
|
- Device friendly name and icon
|
||||||
- Detailed information (if available): time, lat, lon, height, battery and speed
|
- Detailed information (if available): time, latitude, longitude, height, battery and speed
|
||||||
|
|
||||||
- Location history (data points, line or both)
|
- Location history (data points, line or both)
|
||||||
- Location heatmap
|
- Location heatmap
|
||||||
- Button to quickly fit all shown objects on the map into view
|
- Button to quickly fit all shown objects on the map into view
|
||||||
|
|
||||||
- Display data in a specific date range
|
- Display data in a specific date range
|
||||||
- Filter by user and device
|
- Filter by user and device
|
||||||
- Customizable:
|
- Customisable:
|
||||||
|
|
||||||
- UI color
|
- UI color
|
||||||
- Default start and end date
|
- Default start and end date
|
||||||
- Map:
|
- Map:
|
||||||
|
|
||||||
- Tile server
|
- Tile server
|
||||||
- Max zoom
|
- Max zoom
|
||||||
- Default position and zoom
|
- Default position and zoom
|
||||||
- Heatmap colors, radius and blur
|
- Heatmap colors, radius and blur
|
||||||
|
|
||||||
|
See [`docs/config.md`](docs/config.md) for more info.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
_Click to enlarge._
|
_Click to enlarge._
|
||||||
@@ -78,18 +89,6 @@ _Click to enlarge._
|
|||||||
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" alt="Heatmap" height="200"></a>
|
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" alt="Heatmap" height="200"></a>
|
||||||
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" alt="Customized" height="200"></a>
|
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" alt="Customized" height="200"></a>
|
||||||
|
|
||||||
## ToDo
|
|
||||||
|
|
||||||
- Node.js based development workflow:
|
|
||||||
|
|
||||||
- Webpack
|
|
||||||
- Vue SFCs
|
|
||||||
- Sass
|
|
||||||
- Dependency management with yarn instead of a local copy or unpkg.com
|
|
||||||
|
|
||||||
- Add documentation, at least for the config file
|
|
||||||
- Download data for selected date range, user and device as JSON
|
|
||||||
|
|
||||||
## Contributing
|
## 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!
|
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!
|
||||||
|
|||||||
3
babel.config.js
Normal file
3
babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: ["@vue/app"],
|
||||||
|
};
|
||||||
224
index.html
224
index.html
@@ -1,224 +0,0 @@
|
|||||||
<!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>
|
|
||||||
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "owntracks-ui",
|
||||||
|
"version": "2.0.0-alpha",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": {
|
||||||
|
"name": "Linus Groh",
|
||||||
|
"email": "mail@linusgroh.de"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/owntracks/frontend.git"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build",
|
||||||
|
"lint": "vue-cli-service lint",
|
||||||
|
"cors-proxy": "node scripts/corsProxy.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"deepmerge": "^4.0.0",
|
||||||
|
"leaflet": "^1.5.1",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
|
"vue": "^2.6.6",
|
||||||
|
"vue-feather-icons": "^5.0.0",
|
||||||
|
"vue-router": "^3.1.3",
|
||||||
|
"vue2-leaflet": "^2.2.1",
|
||||||
|
"vue2-leaflet-heatmap": "^1.0.5",
|
||||||
|
"vuejs-datepicker": "^1.6.2",
|
||||||
|
"vuex": "^3.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/cli-plugin-babel": "^3.11.0",
|
||||||
|
"@vue/cli-plugin-eslint": "^3.11.0",
|
||||||
|
"@vue/cli-service": "^3.11.0",
|
||||||
|
"@vue/eslint-config-prettier": "^5.0.0",
|
||||||
|
"babel-eslint": "^10.0.3",
|
||||||
|
"cors-anywhere": "^0.4.1",
|
||||||
|
"eslint": "^6.4.0",
|
||||||
|
"eslint-plugin-prettier": "^3.1.1",
|
||||||
|
"eslint-plugin-vue": "^5.2.3",
|
||||||
|
"lint-staged": "^9.4.0",
|
||||||
|
"node-sass": "^4.12.0",
|
||||||
|
"sass-loader": "^8.0.0",
|
||||||
|
"vue-template-compiler": "^2.5.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
3
public/config/config.default.js
Normal file
3
public/config/config.default.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Here you can overwite the default configúration values
|
||||||
|
window.owntracks = window.owntracks || {};
|
||||||
|
window.owntracks.config = {};
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
18
public/index.html
Normal file
18
public/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||||
|
<title>OwnTracks UI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but OwnTracks doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="<%= BASE_URL %>config/config.js"></script>
|
||||||
|
<!-- built files will be auto injected -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
scripts/corsProxy.js
Normal file
25
scripts/corsProxy.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const corsProxy = require("cors-anywhere");
|
||||||
|
|
||||||
|
const host = process.env.OT_PROXY_HOST || "0.0.0.0";
|
||||||
|
const port = process.env.OT_PROXY_PORT || 8888;
|
||||||
|
const username = process.env.OT_BASIC_AUTH_USERNAME || null;
|
||||||
|
const password = process.env.OT_BASIC_AUTH_PASSWORD || null;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
httpProxyOptions: {
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (username !== null && password !== null) {
|
||||||
|
console.log(`Basic auth for user ${username} enabled`);
|
||||||
|
options.setHeaders = {
|
||||||
|
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString(
|
||||||
|
"base64"
|
||||||
|
)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
corsProxy.createServer(options).listen(port, host, () => {
|
||||||
|
console.log(`Running CORS Anywhere on http://${host}:${port}`);
|
||||||
|
});
|
||||||
107
src/App.vue
Normal file
107
src/App.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<AppHeader />
|
||||||
|
<main>
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
<Modal name="download">
|
||||||
|
Not implemented.
|
||||||
|
</Modal>
|
||||||
|
<Modal name="information">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/owntracks/frontend">
|
||||||
|
owntracks/frontend
|
||||||
|
</a>
|
||||||
|
({{ frontendVersion }})
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/owntracks/recorder">
|
||||||
|
owntracks/recorder
|
||||||
|
</a>
|
||||||
|
({{ recorderVersion }})
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://owntracks.org/booklet/">
|
||||||
|
OwnTracks Recorder Documentation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://twitter.com/OwnTracks">@OwnTracks</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapActions, mapMutations, mapState } from "vuex";
|
||||||
|
|
||||||
|
import config from "@/config";
|
||||||
|
import * as types from "@/store/mutation-types";
|
||||||
|
import AppHeader from "@/components/AppHeader";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { AppHeader, Modal },
|
||||||
|
created() {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--color-accent",
|
||||||
|
config.accentColor
|
||||||
|
);
|
||||||
|
this.populateStateFromQuery(this.$route.query);
|
||||||
|
this.loadData();
|
||||||
|
// Update URL query params when relevant state changes
|
||||||
|
this.$store.subscribe(
|
||||||
|
mutation =>
|
||||||
|
[
|
||||||
|
types.SET_SELECTED_USER,
|
||||||
|
types.SET_SELECTED_DEVICE,
|
||||||
|
types.SET_START_DATE,
|
||||||
|
types.SET_END_DATE,
|
||||||
|
types.SET_MAP_CENTER,
|
||||||
|
types.SET_MAP_ZOOM,
|
||||||
|
types.SET_MAP_LAYER_VISIBILITY,
|
||||||
|
].includes(mutation.type) && this.updateUrlQuery()
|
||||||
|
);
|
||||||
|
// Initially update URL query params from state
|
||||||
|
this.updateUrlQuery();
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(["frontendVersion", "recorderVersion"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapMutations({
|
||||||
|
setModalVisibility: types.SET_MODAL_VISIBILITY,
|
||||||
|
}),
|
||||||
|
...mapActions(["populateStateFromQuery", "loadData"]),
|
||||||
|
updateUrlQuery() {
|
||||||
|
const {
|
||||||
|
map,
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
selectedUser: user,
|
||||||
|
selectedDevice: device,
|
||||||
|
} = this.$store.state;
|
||||||
|
const activeLayers = Object.keys(map.layers).filter(
|
||||||
|
key => map.layers[key] === true
|
||||||
|
);
|
||||||
|
const query = {
|
||||||
|
lat: map.center.lat,
|
||||||
|
lng: map.center.lng,
|
||||||
|
zoom: map.zoom,
|
||||||
|
start: start.toISOString().split("T")[0],
|
||||||
|
end: end.toISOString().split("T")[0],
|
||||||
|
...(user !== null && { user }),
|
||||||
|
...(user !== null && device !== null && { device }),
|
||||||
|
...(activeLayers.length > 0 && { layers: activeLayers.join(",") }),
|
||||||
|
};
|
||||||
|
this.$router.replace({ query }).catch(() => {}); // https://github.com/vuejs/vue-router/issues/2872#issuecomment-519073998
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import "styles/main";
|
||||||
|
</style>
|
||||||
119
src/api.js
Normal file
119
src/api.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { getApiUrl } from "@/util";
|
||||||
|
|
||||||
|
const fetchApi = (path, params = {}) => {
|
||||||
|
const url = getApiUrl(path);
|
||||||
|
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
|
||||||
|
return fetch(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVersion = async () => {
|
||||||
|
const response = await fetchApi("/api/0/version");
|
||||||
|
const json = await response.json();
|
||||||
|
const version = json.version;
|
||||||
|
return version;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUsers = async () => {
|
||||||
|
const response = await fetchApi("/api/0/list");
|
||||||
|
const json = await response.json();
|
||||||
|
const users = json.results;
|
||||||
|
return users;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDevices = async users => {
|
||||||
|
const devices = {};
|
||||||
|
await Promise.all(
|
||||||
|
users.map(async user => {
|
||||||
|
const response = await fetchApi(`/api/0/list`, { user });
|
||||||
|
const json = await response.json();
|
||||||
|
const userDevices = json.results;
|
||||||
|
devices[user] = userDevices;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return devices;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLastLocations = async (user, device) => {
|
||||||
|
const params = {};
|
||||||
|
if (user) {
|
||||||
|
params["user"] = user;
|
||||||
|
if (device) {
|
||||||
|
params["device"] = device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await fetchApi("/api/0/last", params);
|
||||||
|
const json = await response.json();
|
||||||
|
return json;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserDeviceLocationHistory = async (
|
||||||
|
user,
|
||||||
|
device,
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
) => {
|
||||||
|
const startDate = start.toISOString().split("T")[0];
|
||||||
|
const endDate = end.toISOString().split("T")[0];
|
||||||
|
const response = await fetchApi("/api/0/locations", {
|
||||||
|
from: `${startDate}T00:00:00`,
|
||||||
|
to: `${endDate}T23:59:59`,
|
||||||
|
user,
|
||||||
|
device,
|
||||||
|
format: "json",
|
||||||
|
});
|
||||||
|
const json = await response.json();
|
||||||
|
return json.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLocationHistory = async (devices, start, end) => {
|
||||||
|
const locationHistory = {};
|
||||||
|
await Promise.all(
|
||||||
|
Object.keys(devices).map(async user => {
|
||||||
|
locationHistory[user] = {};
|
||||||
|
await Promise.all(
|
||||||
|
devices[user].map(async device => {
|
||||||
|
locationHistory[user][device] = await getUserDeviceLocationHistory(
|
||||||
|
user,
|
||||||
|
device,
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return locationHistory;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const connectWebsocket = async callback => {
|
||||||
|
let url = getApiUrl("/ws/last");
|
||||||
|
url.protocol = url.protocol.replace("http", "ws");
|
||||||
|
url = url.href;
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
console.info(`[WS] Connecting to ${url}...`);
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.info("[WS] Connected");
|
||||||
|
ws.send("LAST");
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.info("[WS] Disconnected. Reconnecting in one second...");
|
||||||
|
setTimeout(connectWebsocket, 1000);
|
||||||
|
};
|
||||||
|
ws.onmessage = async msg => {
|
||||||
|
if (msg.data) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(msg.data);
|
||||||
|
if (data._type === "location") {
|
||||||
|
console.info("[WS] Location update received");
|
||||||
|
callback && (await callback());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (msg.data !== "LAST") {
|
||||||
|
console.exception(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.info("[WS] Ping");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
232
src/components/AppHeader.vue
Normal file
232
src/components/AppHeader.vue
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<template>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-item">
|
||||||
|
<button
|
||||||
|
class="button button-outline"
|
||||||
|
title="Automatically center the map view and zoom in to relevant data"
|
||||||
|
@click="$root.$emit('fitView')"
|
||||||
|
>
|
||||||
|
Fit View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<LayersIcon size="1x" />
|
||||||
|
<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"
|
||||||
|
:checked="map.layers.last"
|
||||||
|
@change="
|
||||||
|
setMapLayerVisibility({
|
||||||
|
layer: 'last',
|
||||||
|
visibility: $event.target.checked,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
Show last known locations
|
||||||
|
</label>
|
||||||
|
<label tabindex="0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="map.layers.line"
|
||||||
|
@change="
|
||||||
|
setMapLayerVisibility({
|
||||||
|
layer: 'line',
|
||||||
|
visibility: $event.target.checked,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
Show location history (line)
|
||||||
|
</label>
|
||||||
|
<label tabindex="0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="map.layers.points"
|
||||||
|
@change="
|
||||||
|
setMapLayerVisibility({
|
||||||
|
layer: 'points',
|
||||||
|
visibility: $event.target.checked,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
Show location history (points)
|
||||||
|
</label>
|
||||||
|
<label tabindex="0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="map.layers.heatmap"
|
||||||
|
@change="
|
||||||
|
setMapLayerVisibility({
|
||||||
|
layer: 'heatmap',
|
||||||
|
visibility: $event.target.checked,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
Show location heatmap
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<CalendarIcon size="1x" />
|
||||||
|
<Datepicker
|
||||||
|
v-model="startDate"
|
||||||
|
:use-utc="true"
|
||||||
|
:disabled-dates="startDateDisabledDates"
|
||||||
|
title="Select start date"
|
||||||
|
/>
|
||||||
|
to
|
||||||
|
<Datepicker
|
||||||
|
v-model="endDate"
|
||||||
|
:use-utc="true"
|
||||||
|
:disabled-dates="endDateDisabledDates"
|
||||||
|
title="Select end date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<UserIcon size="1x" />
|
||||||
|
<select
|
||||||
|
v-model="selectedUser"
|
||||||
|
class="dropdown-button button"
|
||||||
|
title="Select user"
|
||||||
|
>
|
||||||
|
<option :value="null">
|
||||||
|
Show All
|
||||||
|
</option>
|
||||||
|
<option v-for="user in users" :value="user" :key="user">
|
||||||
|
{{ user }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedUser" class="nav-item">
|
||||||
|
<SmartphoneIcon size="1x" />
|
||||||
|
<select
|
||||||
|
v-model="selectedDevice"
|
||||||
|
class="dropdown-button button"
|
||||||
|
title="Select device"
|
||||||
|
>
|
||||||
|
<option :value="null">
|
||||||
|
Show All
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="device in devices[selectedUser]"
|
||||||
|
:value="device"
|
||||||
|
:key="`${selectedUser}-${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="
|
||||||
|
setModalVisibility({
|
||||||
|
modal: 'download',
|
||||||
|
visibility: true,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DownloadIcon size="1x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<button
|
||||||
|
class="button button-flat button-icon"
|
||||||
|
title="Information"
|
||||||
|
@click="
|
||||||
|
setModalVisibility({
|
||||||
|
modal: 'information',
|
||||||
|
visibility: true,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<InfoIcon size="1x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapActions, mapGetters, mapMutations, mapState } from "vuex";
|
||||||
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
InfoIcon,
|
||||||
|
LayersIcon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from "vue-feather-icons";
|
||||||
|
import Datepicker from "vuejs-datepicker";
|
||||||
|
|
||||||
|
import * as types from "@/store/mutation-types";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
CalendarIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
InfoIcon,
|
||||||
|
LayersIcon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
UserIcon,
|
||||||
|
Datepicker,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(["users", "devices", "map"]),
|
||||||
|
...mapGetters(["startDateDisabledDates", "endDateDisabledDates"]),
|
||||||
|
selectedUser: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.selectedUser;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setSelectedUser(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selectedDevice: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.selectedDevice;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setSelectedDevice(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.startDate;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setStartDate(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.endDate;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setEndDate(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapMutations({
|
||||||
|
setMapLayerVisibility: types.SET_MAP_LAYER_VISIBILITY,
|
||||||
|
setModalVisibility: types.SET_MODAL_VISIBILITY,
|
||||||
|
}),
|
||||||
|
...mapActions([
|
||||||
|
"setSelectedUser",
|
||||||
|
"setSelectedDevice",
|
||||||
|
"setStartDate",
|
||||||
|
"setEndDate",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
89
src/components/LDeviceLocationPopup.vue
Normal file
89
src/components/LDeviceLocationPopup.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<LPopup>
|
||||||
|
<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>
|
||||||
|
<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 speed === 'number'" class="location-popup-detail">
|
||||||
|
<span class="mdi mdi-16px mdi-speedometer"></span>
|
||||||
|
{{ speed }} km/h
|
||||||
|
</div>
|
||||||
|
</LPopup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { LPopup } from "vue2-leaflet";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "LDeviceLocationPopup",
|
||||||
|
components: {
|
||||||
|
LPopup,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
faceImageDataURI() {
|
||||||
|
return `data:image/png;base64,${this.face}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
39
src/components/Modal.vue
Normal file
39
src/components/Modal.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div class="modal" v-show="modals[name]" @click.self="close">
|
||||||
|
<div class="modal-container">
|
||||||
|
<button class="modal-close-button" title="Close" @click="close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapMutations, mapState } from "vuex";
|
||||||
|
|
||||||
|
import * as types from "@/store/mutation-types";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(["modals"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapMutations({
|
||||||
|
setModalVisibility: types.SET_MODAL_VISIBILITY,
|
||||||
|
}),
|
||||||
|
close() {
|
||||||
|
this.setModalVisibility({
|
||||||
|
modal: this.name,
|
||||||
|
visibility: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
103
src/config.js
Normal file
103
src/config.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import deepmerge from "deepmerge";
|
||||||
|
|
||||||
|
const endDate = new Date();
|
||||||
|
endDate.setUTCHours(0);
|
||||||
|
endDate.setUTCMinutes(0);
|
||||||
|
endDate.setUTCSeconds(0);
|
||||||
|
|
||||||
|
const startDate = new Date(endDate);
|
||||||
|
startDate.setUTCMonth(startDate.getMonth() - 1);
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
api: {
|
||||||
|
// API base URL, defaults to the same domain. Keep CORS in mind.
|
||||||
|
baseUrl: `${window.location.protocol}//${window.location.host}`,
|
||||||
|
},
|
||||||
|
accentColor: "#478db2",
|
||||||
|
// Initial start and end date. Doesn't have to be hardcoded, see
|
||||||
|
// above. Defaults to one month ago - today.
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
// User and device selected by default. Set to null to show all by default.
|
||||||
|
selectedUser: null,
|
||||||
|
selectedDevice: null,
|
||||||
|
map: {
|
||||||
|
// Initial map center position
|
||||||
|
center: { lat: 0, lng: 0 },
|
||||||
|
// Initial map zoom
|
||||||
|
zoom: 19,
|
||||||
|
// This is being used to fetch tiles in different resolutions -
|
||||||
|
// set to the highest value the configured tileserver supports.
|
||||||
|
maxNativeZoom: 19,
|
||||||
|
// Allow zooming closer than the tile server supports, which will
|
||||||
|
// result in (slightly) blurry tiles on higher zoom levels. Set
|
||||||
|
// to the same value as `maxNativeZoom` to disable.
|
||||||
|
maxZoom: 21,
|
||||||
|
// Tile server URL. See Leaflet documentation for more info.
|
||||||
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
// Leaflet map controls. Options should be self-explanatory.
|
||||||
|
controls: {
|
||||||
|
zoom: {
|
||||||
|
display: true,
|
||||||
|
position: "topleft",
|
||||||
|
},
|
||||||
|
scale: {
|
||||||
|
display: true,
|
||||||
|
position: "bottomleft",
|
||||||
|
maxWidth: 200,
|
||||||
|
metric: true,
|
||||||
|
imperial: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// `color` and `fillColor` default to `accentColor` when null.
|
||||||
|
polyline: {
|
||||||
|
color: null,
|
||||||
|
fillColor: "transparent",
|
||||||
|
},
|
||||||
|
circle: {
|
||||||
|
color: null,
|
||||||
|
fillColor: null,
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
},
|
||||||
|
circleMarker: {
|
||||||
|
color: null,
|
||||||
|
fillColor: "#fff",
|
||||||
|
fillOpacity: 1,
|
||||||
|
radius: 4,
|
||||||
|
},
|
||||||
|
// Configuration for the heatmap (simpleheat). See
|
||||||
|
// https://github.com/mourner/simpleheat for more info.
|
||||||
|
heatmap: {
|
||||||
|
max: 20,
|
||||||
|
radius: 25,
|
||||||
|
blur: 15,
|
||||||
|
// Uses simpleheat's default gradient when null. See
|
||||||
|
// https://github.com/mourner/simpleheat/blob/c1998c36fa2f9a31350371fd42ee30eafcc78f9c/simpleheat.js#L22-L28
|
||||||
|
gradient: null,
|
||||||
|
},
|
||||||
|
// Which layers to show by default. The source of truth at runtime
|
||||||
|
// is the Vuex store, which is initialised from these values and
|
||||||
|
// the query parameters, in that order.
|
||||||
|
layers: {
|
||||||
|
last: true,
|
||||||
|
line: true,
|
||||||
|
points: false,
|
||||||
|
heatmap: false,
|
||||||
|
},
|
||||||
|
// Maximum distance (in meters) between points for them to be part
|
||||||
|
// of the the same line. This avoids straight lines going across
|
||||||
|
// the map when there's a large distance between two points (which
|
||||||
|
// usually indicates that they're not related). Set to Infinity to
|
||||||
|
// disable splitting into separate lines.
|
||||||
|
maxPointDistance: 1000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use deepmerge to combine the default and user-defined configuration.
|
||||||
|
// This enables the user to use a fairly small config object which only
|
||||||
|
// needs to contain actual changes, not all default values - and these
|
||||||
|
// stay up-to-date automatically.
|
||||||
|
// There might not be a user-defined config, default to an empty object.
|
||||||
|
export default deepmerge(DEFAULT_CONFIG, (window.owntracks || {}).config || {});
|
||||||
9
src/constants.js
Normal file
9
src/constants.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Regular expression for an ISO 8601 YYYY-MM-DD date.
|
||||||
|
// Used to validate dates from URL query parameters.
|
||||||
|
export const ISO_DATE_REGEXP = new RegExp(
|
||||||
|
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
|
||||||
|
);
|
||||||
|
|
||||||
|
// https://en.wikipedia.org/wiki/Earth_radius
|
||||||
|
// Used to calculate the distance between two coordinates.
|
||||||
|
export const EARTH_RADIUS_IN_KM = 6371;
|
||||||
12
src/main.js
Normal file
12
src/main.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Vue from "vue";
|
||||||
|
import App from "@/App.vue";
|
||||||
|
import router from "@/router";
|
||||||
|
import store from "@/store";
|
||||||
|
|
||||||
|
Vue.config.productionTip = false;
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
router,
|
||||||
|
store,
|
||||||
|
render: h => h(App),
|
||||||
|
}).$mount("#app");
|
||||||
17
src/router.js
Normal file
17
src/router.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Vue from "vue";
|
||||||
|
import Router from "vue-router";
|
||||||
|
import Map from "./views/Map.vue";
|
||||||
|
|
||||||
|
Vue.use(Router);
|
||||||
|
|
||||||
|
export default new Router({
|
||||||
|
mode: "history",
|
||||||
|
base: process.env.BASE_URL,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "map",
|
||||||
|
component: Map,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
184
src/store/actions.js
Normal file
184
src/store/actions.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import * as types from "@/store/mutation-types";
|
||||||
|
import * as api from "@/api";
|
||||||
|
import { isIsoDate } from "@/util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate the state from URL query parameters.
|
||||||
|
*
|
||||||
|
* @param {Object} query URL query parameters
|
||||||
|
*/
|
||||||
|
const populateStateFromQuery = ({ state, commit }, query) => {
|
||||||
|
if (query.lat && !isNaN(parseFloat(query.lat))) {
|
||||||
|
commit(types.SET_MAP_CENTER, {
|
||||||
|
lat: query.lat,
|
||||||
|
lng: parseFloat(state.map.center.lng),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.lng && !isNaN(parseFloat(query.lng))) {
|
||||||
|
commit(types.SET_MAP_CENTER, {
|
||||||
|
lat: parseFloat(state.map.center.lat),
|
||||||
|
lng: query.lng,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.zoom && !isNaN(parseInt(query.zoom))) {
|
||||||
|
commit(types.SET_MAP_ZOOM, parseInt(query.zoom));
|
||||||
|
}
|
||||||
|
if (query.start && isIsoDate(query.start)) {
|
||||||
|
commit(types.SET_START_DATE, new Date(query.start));
|
||||||
|
}
|
||||||
|
if (query.end && isIsoDate(query.end)) {
|
||||||
|
commit(types.SET_END_DATE, new Date(query.end));
|
||||||
|
}
|
||||||
|
if (query.user) {
|
||||||
|
commit(types.SET_SELECTED_USER, query.user);
|
||||||
|
}
|
||||||
|
if (query.device) {
|
||||||
|
commit(types.SET_SELECTED_DEVICE, query.device);
|
||||||
|
}
|
||||||
|
if (query.layers) {
|
||||||
|
const activeLayers = query.layers.split(",");
|
||||||
|
Object.keys(state.map.layers).forEach(layer => {
|
||||||
|
const visibility = activeLayers.includes(layer);
|
||||||
|
if (state.map.layers[layer] !== visibility) {
|
||||||
|
commit(types.SET_MAP_LAYER_VISIBILITY, { layer, visibility });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger loading of all required data: users, devices, last locations,
|
||||||
|
* location history, version and initiate WebSocket connection.
|
||||||
|
*/
|
||||||
|
const loadData = async ({ dispatch }) => {
|
||||||
|
await dispatch("getUsers");
|
||||||
|
await dispatch("getDevices");
|
||||||
|
await dispatch("getLastLocations");
|
||||||
|
await dispatch("getLocationHistory");
|
||||||
|
await dispatch("getRecorderVersion");
|
||||||
|
await dispatch("connectWebsocket");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to WebSocket to receive live location updates. When an update is
|
||||||
|
* received, reload last locations and location history.
|
||||||
|
*/
|
||||||
|
const connectWebsocket = async ({ dispatch }) => {
|
||||||
|
api.connectWebsocket(async () => {
|
||||||
|
await dispatch("getLastLocations");
|
||||||
|
// Reloading the complete location history is necessary because the
|
||||||
|
// last locations do lack some of the detailed information.
|
||||||
|
// TODO: make this optional via config.
|
||||||
|
await dispatch("getLocationHistory");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load user names.
|
||||||
|
*/
|
||||||
|
const getUsers = async ({ commit }) => {
|
||||||
|
commit(types.SET_USERS, await api.getUsers());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load devices names of all users.
|
||||||
|
*/
|
||||||
|
const getDevices = async ({ commit, state }) => {
|
||||||
|
commit(types.SET_DEVICES, await api.getDevices(state.users));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load last location of the selected user/device.
|
||||||
|
*/
|
||||||
|
const getLastLocations = async ({ commit, state }) => {
|
||||||
|
commit(
|
||||||
|
types.SET_LAST_LOCATIONS,
|
||||||
|
await api.getLastLocations(state.selectedUser, state.selectedDevice)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load location history of all devices, in the selected date range.
|
||||||
|
*/
|
||||||
|
const getLocationHistory = async ({ commit, state }) => {
|
||||||
|
let devices;
|
||||||
|
if (state.selectedUser) {
|
||||||
|
if (state.selectedDevice) {
|
||||||
|
devices = { [state.selectedUser]: [state.selectedDevice] };
|
||||||
|
} else {
|
||||||
|
devices = { [state.selectedUser]: state.devices[state.selectedUser] };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
devices = state.devices;
|
||||||
|
}
|
||||||
|
commit(
|
||||||
|
types.SET_LOCATION_HISTORY,
|
||||||
|
await api.getLocationHistory(devices, state.startDate, state.endDate)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the OwnTracks recorder version.
|
||||||
|
*/
|
||||||
|
const getRecorderVersion = async ({ commit }) => {
|
||||||
|
commit(types.SET_RECORDER_VERSION, await api.getVersion());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the selected user and reload the location history.
|
||||||
|
*
|
||||||
|
* @param {string} user Name of the new selected user
|
||||||
|
*/
|
||||||
|
const setSelectedUser = async ({ commit, dispatch }, user) => {
|
||||||
|
commit(types.SET_SELECTED_USER, user);
|
||||||
|
await dispatch("getLocationHistory");
|
||||||
|
await dispatch("getLastLocations");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the selected device and reload the location history.
|
||||||
|
*
|
||||||
|
* @param {string} device Name of the new selected device
|
||||||
|
*/
|
||||||
|
const setSelectedDevice = async ({ commit, dispatch }, device) => {
|
||||||
|
commit(types.SET_SELECTED_DEVICE, device);
|
||||||
|
await dispatch("getLocationHistory");
|
||||||
|
await dispatch("getLastLocations");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the start date for loading data and reload the location history.
|
||||||
|
*
|
||||||
|
* @param {Date} startDate Start date for loading data
|
||||||
|
*/
|
||||||
|
const setStartDate = async ({ commit, dispatch }, startDate) => {
|
||||||
|
commit(types.SET_START_DATE, startDate);
|
||||||
|
await dispatch("getLocationHistory");
|
||||||
|
await dispatch("getLastLocations");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the end date for loading data and reload the location history.
|
||||||
|
*
|
||||||
|
* @param {Date} endDate End date for loading data
|
||||||
|
*/
|
||||||
|
const setEndDate = async ({ commit, dispatch }, endDate) => {
|
||||||
|
commit(types.SET_END_DATE, endDate);
|
||||||
|
await dispatch("getLocationHistory");
|
||||||
|
await dispatch("getLastLocations");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
populateStateFromQuery,
|
||||||
|
loadData,
|
||||||
|
connectWebsocket,
|
||||||
|
getUsers,
|
||||||
|
getDevices,
|
||||||
|
getLastLocations,
|
||||||
|
getLocationHistory,
|
||||||
|
getRecorderVersion,
|
||||||
|
setSelectedUser,
|
||||||
|
setSelectedDevice,
|
||||||
|
setStartDate,
|
||||||
|
setEndDate,
|
||||||
|
};
|
||||||
93
src/store/getters.js
Normal file
93
src/store/getters.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
import config from "@/config";
|
||||||
|
import { distanceBetweenCoordinates } from "@/util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From the selected users' and devices' location histories, create an
|
||||||
|
* array of all coordinates.
|
||||||
|
*
|
||||||
|
* @param {Object} state.locationHistory Location history of selected users and devices
|
||||||
|
* @return {Array.<L.LatLng>} All coordinates
|
||||||
|
*/
|
||||||
|
const locationHistoryLatLngs = state => {
|
||||||
|
const latLngs = [];
|
||||||
|
Object.keys(state.locationHistory).forEach(user => {
|
||||||
|
Object.keys(state.locationHistory[user]).forEach(device => {
|
||||||
|
state.locationHistory[user][device].forEach(coordinate => {
|
||||||
|
latLngs.push(L.latLng(coordinate.lat, coordinate.lon));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return latLngs;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From the selected users' and devices' location histories, create an
|
||||||
|
* array of coordinate groups where the distance between two subsequent
|
||||||
|
* coordinates does not exceed `config.map.maxPointDistance`.
|
||||||
|
*
|
||||||
|
* @param {Object} state.locationHistory Location history of selected users and devices
|
||||||
|
* @return {Array.<Array.<L.LatLng>>} Groups of coherent coordinates
|
||||||
|
*/
|
||||||
|
const locationHistoryLatLngGroups = state => {
|
||||||
|
const groups = [];
|
||||||
|
Object.keys(state.locationHistory).forEach(user => {
|
||||||
|
Object.keys(state.locationHistory[user]).forEach(device => {
|
||||||
|
let latLngs = [];
|
||||||
|
state.locationHistory[user][device].forEach(coordinate => {
|
||||||
|
const latLng = L.latLng(coordinate.lat, coordinate.lon);
|
||||||
|
// Skip if group splitting is disabled or this is the first
|
||||||
|
// coordinate in the current group
|
||||||
|
if (config.map.maxPointDistance !== null && latLngs.length > 0) {
|
||||||
|
const lastLatLng = latLngs.slice(-1)[0];
|
||||||
|
if (
|
||||||
|
distanceBetweenCoordinates(lastLatLng, latLng) >
|
||||||
|
config.map.maxPointDistance
|
||||||
|
) {
|
||||||
|
// Distance is too far, start new group of coordinate
|
||||||
|
groups.push(latLngs);
|
||||||
|
latLngs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add coordinate to current active group
|
||||||
|
latLngs.push(latLng);
|
||||||
|
});
|
||||||
|
groups.push(latLngs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the start date selector, disable all dates above the end date
|
||||||
|
* or current date.
|
||||||
|
*
|
||||||
|
* @param {Date} state.endDate End date
|
||||||
|
* @return {Object} Configuration for the `disabled-dates` prop of the `vuejs-datepicker` component
|
||||||
|
*/
|
||||||
|
const startDateDisabledDates = state => {
|
||||||
|
return {
|
||||||
|
customPredictor: date => date > state.endDate || date > new Date(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the end date selector, disable all dates below the start date
|
||||||
|
* or above the current date.
|
||||||
|
*
|
||||||
|
* @param {Date} state.startDate Start date
|
||||||
|
* @return {Object} Configuration for the `disabled-dates` prop of the `vuejs-datepicker` component
|
||||||
|
*/
|
||||||
|
const endDateDisabledDates = state => {
|
||||||
|
return {
|
||||||
|
customPredictor: date => date < state.startDate || date > new Date(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
locationHistoryLatLngs,
|
||||||
|
locationHistoryLatLngGroups,
|
||||||
|
startDateDisabledDates,
|
||||||
|
endDateDisabledDates,
|
||||||
|
};
|
||||||
36
src/store/index.js
Normal file
36
src/store/index.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Vue from "vue";
|
||||||
|
import Vuex from "vuex";
|
||||||
|
|
||||||
|
import getters from "@/store/getters";
|
||||||
|
import mutations from "@/store/mutations";
|
||||||
|
import actions from "@/store/actions";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
|
Vue.use(Vuex);
|
||||||
|
|
||||||
|
export default new Vuex.Store({
|
||||||
|
state: {
|
||||||
|
frontendVersion: process.env.PACKAGE_VERSION,
|
||||||
|
recorderVersion: "",
|
||||||
|
users: [],
|
||||||
|
devices: {},
|
||||||
|
lastLocations: [],
|
||||||
|
locationHistory: {},
|
||||||
|
selectedUser: config.selectedUser,
|
||||||
|
selectedDevice: config.selectedDevice,
|
||||||
|
startDate: config.startDate,
|
||||||
|
endDate: config.endDate,
|
||||||
|
map: {
|
||||||
|
center: config.map.center,
|
||||||
|
zoom: config.map.zoom,
|
||||||
|
layers: config.map.layers,
|
||||||
|
},
|
||||||
|
modals: {
|
||||||
|
download: false,
|
||||||
|
information: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getters,
|
||||||
|
mutations,
|
||||||
|
actions,
|
||||||
|
});
|
||||||
13
src/store/mutation-types.js
Normal file
13
src/store/mutation-types.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const SET_RECORDER_VERSION = "SET_RECORDER_VERSION";
|
||||||
|
export const SET_USERS = "SET_USERS";
|
||||||
|
export const SET_DEVICES = "SET_DEVICES";
|
||||||
|
export const SET_LAST_LOCATIONS = "SET_LAST_LOCATIONS";
|
||||||
|
export const SET_LOCATION_HISTORY = "SET_LOCATION_HISTORY";
|
||||||
|
export const SET_SELECTED_USER = "SET_SELECTED_USER";
|
||||||
|
export const SET_SELECTED_DEVICE = "SET_SELECTED_DEVICE";
|
||||||
|
export const SET_START_DATE = "SET_START_DATE";
|
||||||
|
export const SET_END_DATE = "SET_END_DATE";
|
||||||
|
export const SET_MAP_CENTER = "SET_MAP_CENTER";
|
||||||
|
export const SET_MAP_ZOOM = "SET_MAP_ZOOM";
|
||||||
|
export const SET_MAP_LAYER_VISIBILITY = "SET_MAP_LAYER_VISIBILITY";
|
||||||
|
export const SET_MODAL_VISIBILITY = "SET_MODAL_VISIBILITY";
|
||||||
43
src/store/mutations.js
Normal file
43
src/store/mutations.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as types from "@/store/mutation-types";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
[types.SET_RECORDER_VERSION](state, version) {
|
||||||
|
state.recorderVersion = version;
|
||||||
|
},
|
||||||
|
[types.SET_USERS](state, users) {
|
||||||
|
state.users = users;
|
||||||
|
},
|
||||||
|
[types.SET_DEVICES](state, devices) {
|
||||||
|
state.devices = devices;
|
||||||
|
},
|
||||||
|
[types.SET_LAST_LOCATIONS](state, lastLocations) {
|
||||||
|
state.lastLocations = lastLocations;
|
||||||
|
},
|
||||||
|
[types.SET_LOCATION_HISTORY](state, locationHistory) {
|
||||||
|
state.locationHistory = locationHistory;
|
||||||
|
},
|
||||||
|
[types.SET_SELECTED_USER](state, selectedUser) {
|
||||||
|
state.selectedUser = selectedUser;
|
||||||
|
},
|
||||||
|
[types.SET_SELECTED_DEVICE](state, selectedDevice) {
|
||||||
|
state.selectedDevice = selectedDevice;
|
||||||
|
},
|
||||||
|
[types.SET_START_DATE](state, startDate) {
|
||||||
|
state.startDate = startDate;
|
||||||
|
},
|
||||||
|
[types.SET_END_DATE](state, endDate) {
|
||||||
|
state.endDate = endDate;
|
||||||
|
},
|
||||||
|
[types.SET_MAP_CENTER](state, center) {
|
||||||
|
state.map.center = center;
|
||||||
|
},
|
||||||
|
[types.SET_MAP_ZOOM](state, zoom) {
|
||||||
|
state.map.zoom = zoom;
|
||||||
|
},
|
||||||
|
[types.SET_MAP_LAYER_VISIBILITY](state, { layer, visibility }) {
|
||||||
|
state.map.layers[layer] = visibility;
|
||||||
|
},
|
||||||
|
[types.SET_MODAL_VISIBILITY](state, { modal, visibility }) {
|
||||||
|
state.modals[modal] = visibility;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--color-text: #333;
|
--color-text: #333;
|
||||||
--color-background: #fff;
|
--color-background: #fff;
|
||||||
--color-accent: #3388ff;
|
--color-accent: #478db2;
|
||||||
--color-accent-text: #fff;
|
--color-accent-text: #fff;
|
||||||
--drop-shadow: drop-shadow(0 10px 10px rgb(0, 0, 0, 0.2));
|
--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");
|
--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");
|
||||||
@@ -31,6 +31,13 @@ ul {
|
|||||||
list-style: inside;
|
list-style: inside;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vue2leaflet-map {
|
||||||
|
height: 100vh;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
@@ -42,6 +49,7 @@ ul {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
z-index: 1;
|
||||||
color: var(--color-accent-text);
|
color: var(--color-accent-text);
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
}
|
}
|
||||||
@@ -69,8 +77,6 @@ ul {
|
|||||||
|
|
||||||
#app > main {
|
#app > main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
/* https://github.com/linusg/owntracks-ui/issues/6 */
|
|
||||||
display: flex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button,
|
.button,
|
||||||
@@ -199,11 +205,6 @@ ul {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container {
|
|
||||||
/* https://github.com/linusg/owntracks-ui/issues/6 */
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-container .leaflet-popup {
|
.leaflet-container .leaflet-popup {
|
||||||
filter: var(--drop-shadow);
|
filter: var(--drop-shadow);
|
||||||
}
|
}
|
||||||
@@ -254,17 +255,14 @@ ul {
|
|||||||
color: var(--color-accent-text);
|
color: var(--color-accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
header .mdi {
|
header .feather {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-right: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 5px;
|
top: -2px;
|
||||||
margin-right: 3px;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
header .button .mdi {
|
.button-icon .feather {
|
||||||
line-height: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mdi-16px.mdi-set,
|
|
||||||
.mdi-16px.mdi::before {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
1
src/styles/main.scss
Normal file
1
src/styles/main.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "base";
|
||||||
27
src/util.js
Normal file
27
src/util.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import config from "@/config";
|
||||||
|
import { ISO_DATE_REGEXP, EARTH_RADIUS_IN_KM } from "@/constants";
|
||||||
|
|
||||||
|
export const getApiUrl = path => new URL(`${config.api.baseUrl}${path}`);
|
||||||
|
export const isIsoDate = s => ISO_DATE_REGEXP.test(s);
|
||||||
|
export const degreesToRadians = degrees => (degrees * Math.PI) / 180;
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/365853/5952681
|
||||||
|
export const distanceBetweenCoordinates = (c1, c2) => {
|
||||||
|
const latDistanceInRad = degreesToRadians(c1.lat - c2.lat);
|
||||||
|
const lngDistanceInRad = degreesToRadians(c1.lng - c2.lng);
|
||||||
|
const lat1InRad = degreesToRadians(c1.lat);
|
||||||
|
const lat2InRad = degreesToRadians(c2.lat);
|
||||||
|
const a =
|
||||||
|
Math.sin(latDistanceInRad / 2) * Math.sin(latDistanceInRad / 2) +
|
||||||
|
Math.sin(lngDistanceInRad / 2) *
|
||||||
|
Math.sin(lngDistanceInRad / 2) *
|
||||||
|
Math.cos(lat1InRad) *
|
||||||
|
Math.cos(lat2InRad);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
// Return distance in meters
|
||||||
|
return EARTH_RADIUS_IN_KM * c * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const capitalizeFirstLetter = string => {
|
||||||
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
|
};
|
||||||
210
src/views/Map.vue
Normal file
210
src/views/Map.vue
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<template>
|
||||||
|
<LMap
|
||||||
|
ref="map"
|
||||||
|
:center="map.center"
|
||||||
|
:zoom="map.zoom"
|
||||||
|
@update:center="setMapCenter"
|
||||||
|
@update:zoom="setMapZoom"
|
||||||
|
>
|
||||||
|
<LControlZoom
|
||||||
|
v-if="controls.zoom.display"
|
||||||
|
:position="controls.zoom.position"
|
||||||
|
/>
|
||||||
|
<LControlScale
|
||||||
|
v-if="controls.scale.display"
|
||||||
|
:position="controls.scale.position"
|
||||||
|
:maxWidth="controls.scale.maxWidth"
|
||||||
|
:metric="controls.scale.metric"
|
||||||
|
:imperial="controls.scale.imperial"
|
||||||
|
/>
|
||||||
|
<LTileLayer
|
||||||
|
:url="url"
|
||||||
|
:attribution="attribution"
|
||||||
|
:options="{ maxNativeZoom, maxZoom }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="map.layers.last">
|
||||||
|
<LCircle
|
||||||
|
v-for="l in lastLocations"
|
||||||
|
:key="`${l.topic}-circle`"
|
||||||
|
:lat-lng="{ lat: l.lat, lng: l.lon }"
|
||||||
|
:radius="l.acc"
|
||||||
|
:color="circle.color"
|
||||||
|
:fill-color="circle.fillColor"
|
||||||
|
:fill-opacity="circle.fillOpacity"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LMarker
|
||||||
|
v-for="l in lastLocations"
|
||||||
|
:key="`${l.topic}-marker`"
|
||||||
|
:lat-lng="[l.lat, l.lon]"
|
||||||
|
>
|
||||||
|
<LDeviceLocationPopup
|
||||||
|
: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"
|
||||||
|
/>
|
||||||
|
</LMarker>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="map.layers.line">
|
||||||
|
<LPolyline
|
||||||
|
v-for="(group, i) in locationHistoryLatLngGroups"
|
||||||
|
:key="i"
|
||||||
|
:lat-lngs="group"
|
||||||
|
:color="polyline.color"
|
||||||
|
:fill-color="polyline.fillColor"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="map.layers.points">
|
||||||
|
<template v-for="(userDevices, user) in locationHistory">
|
||||||
|
<template v-for="(deviceLocations, device) in userDevices">
|
||||||
|
<LCircleMarker
|
||||||
|
v-for="(l, n) in deviceLocations"
|
||||||
|
:key="`${user}-${device}-${n}`"
|
||||||
|
:lat-lng="[l.lat, l.lon]"
|
||||||
|
:radius="circleMarker.radius"
|
||||||
|
:color="circleMarker.color"
|
||||||
|
:fill-color="circleMarker.fillColor"
|
||||||
|
:fill-opacity="circleMarker.fillOpacity"
|
||||||
|
>
|
||||||
|
<LDeviceLocationPopup
|
||||||
|
:user="user"
|
||||||
|
:device="device"
|
||||||
|
:timestamp="l.tst"
|
||||||
|
:lat="l.lat"
|
||||||
|
:lon="l.lon"
|
||||||
|
:alt="l.alt"
|
||||||
|
:battery="l.batt"
|
||||||
|
:speed="l.vel"
|
||||||
|
></LDeviceLocationPopup>
|
||||||
|
</LCircleMarker>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="map.layers.heatmap">
|
||||||
|
<LHeatmap
|
||||||
|
v-if="locationHistoryLatLngs.length"
|
||||||
|
:lat-lng="locationHistoryLatLngs"
|
||||||
|
:max="heatmap.max"
|
||||||
|
:radius="heatmap.radius"
|
||||||
|
:blur="heatmap.blur"
|
||||||
|
:gradient="heatmap.gradient"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</LMap>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters, mapState, mapMutations } from "vuex";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet.heat";
|
||||||
|
import {
|
||||||
|
LMap,
|
||||||
|
LTileLayer,
|
||||||
|
LControlScale,
|
||||||
|
LControlZoom,
|
||||||
|
LMarker,
|
||||||
|
LCircleMarker,
|
||||||
|
LCircle,
|
||||||
|
LPolyline,
|
||||||
|
} from "vue2-leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import markerIcon from "leaflet/dist/images/marker-icon.png";
|
||||||
|
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
|
||||||
|
import markerShadow from "leaflet/dist/images/marker-shadow.png";
|
||||||
|
import LHeatmap from "vue2-leaflet-heatmap";
|
||||||
|
|
||||||
|
import * as types from "@/store/mutation-types";
|
||||||
|
import config from "@/config";
|
||||||
|
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup";
|
||||||
|
|
||||||
|
// See https://github.com/KoRiGaN/Vue2Leaflet/issues/28#issuecomment-299038157
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
LMap,
|
||||||
|
LTileLayer,
|
||||||
|
LControlScale,
|
||||||
|
LControlZoom,
|
||||||
|
LMarker,
|
||||||
|
LCircleMarker,
|
||||||
|
LCircle,
|
||||||
|
LPolyline,
|
||||||
|
LDeviceLocationPopup,
|
||||||
|
LHeatmap,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
center: this.$store.state.map.center,
|
||||||
|
zoom: this.$store.state.map.zoom,
|
||||||
|
maxNativeZoom: config.map.maxNativeZoom,
|
||||||
|
maxZoom: config.map.maxZoom,
|
||||||
|
url: config.map.url,
|
||||||
|
attribution: config.map.attribution,
|
||||||
|
controls: config.map.controls,
|
||||||
|
polyline: {
|
||||||
|
...config.map.polyline,
|
||||||
|
color: config.map.polyline.color || config.accentColor,
|
||||||
|
},
|
||||||
|
circle: {
|
||||||
|
...config.map.circle,
|
||||||
|
color: config.map.circle.color || config.accentColor,
|
||||||
|
fillColor: config.map.circle.fillColor || config.accentColor,
|
||||||
|
},
|
||||||
|
circleMarker: {
|
||||||
|
...config.map.circleMarker,
|
||||||
|
color: config.map.circleMarker.color || config.accentColor,
|
||||||
|
},
|
||||||
|
heatmap: config.map.heatmap,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$root.$on("fitView", () => {
|
||||||
|
this.fitView();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(["locationHistoryLatLngs", "locationHistoryLatLngGroups"]),
|
||||||
|
...mapState(["lastLocations", "locationHistory", "map"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapMutations({
|
||||||
|
setMapCenter: types.SET_MAP_CENTER,
|
||||||
|
setMapZoom: types.SET_MAP_ZOOM,
|
||||||
|
}),
|
||||||
|
fitView() {
|
||||||
|
if (
|
||||||
|
(this.map.layers.line ||
|
||||||
|
this.map.layers.loints ||
|
||||||
|
this.map.layers.heatmap) &&
|
||||||
|
this.locationHistoryLatLngs.length > 0
|
||||||
|
) {
|
||||||
|
this.$refs.map.mapObject.fitBounds(
|
||||||
|
new L.LatLngBounds(this.locationHistoryLatLngs)
|
||||||
|
);
|
||||||
|
} else if (this.map.layers.last && 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.maxNativeZoom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
(() => {
|
|
||||||
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}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
(() => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Here you can overwite values from default.js
|
|
||||||
window.config = {};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
(() => {
|
|
||||||
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
static/main.js
218
static/main.js
@@ -1,218 +0,0 @@
|
|||||||
(() => {
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
17
vue.config.js
Normal file
17
vue.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const webpack = require("webpack");
|
||||||
|
|
||||||
|
const packageJson = fs.readFileSync("./package.json");
|
||||||
|
const version = JSON.parse(packageJson).version;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
configureWebpack: {
|
||||||
|
plugins: [
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
"process.env": {
|
||||||
|
PACKAGE_VERSION: `"${version}"`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user