Compare commits
180 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42f6db024 | ||
|
|
3fbf0a2ff1 | ||
|
|
206eb268fa | ||
|
|
d7266f48f1 | ||
|
|
9ec17e3e9c | ||
|
|
e320441b5e | ||
|
|
1282d93769 | ||
|
|
b8661b11fb | ||
|
|
4f5bfefc36 | ||
|
|
a1faf8153b | ||
|
|
23d73461bc | ||
|
|
90dac4022a | ||
|
|
58b22aeebe | ||
|
|
cb79a4de81 | ||
|
|
c5f491b6fb | ||
|
|
49a9b54a5e | ||
|
|
c449fcaf21 | ||
|
|
1746c359f4 | ||
|
|
6180e52f71 | ||
|
|
0f0f29fcee | ||
|
|
cb0694b032 | ||
|
|
d0f2a99302 | ||
|
|
c8b0ec8b9e | ||
|
|
906eb2a1b4 | ||
|
|
005aab715f | ||
|
|
b76cbdc2e6 | ||
|
|
bb87ec01d4 | ||
|
|
c3e6b775f9 | ||
|
|
a9998e8e3b | ||
|
|
2c4ead262a | ||
|
|
6b1d35be51 | ||
|
|
34cc4895b0 | ||
|
|
57caacb548 | ||
|
|
9783b6f27d | ||
|
|
b262ff602c | ||
|
|
458658865e | ||
|
|
e744e2c001 | ||
|
|
feff6d5272 | ||
|
|
7b88102c2b | ||
|
|
a2c7974f38 | ||
|
|
b3fdf1eabe | ||
|
|
1482833e05 | ||
|
|
04fa3392f0 | ||
|
|
a4334e5273 | ||
|
|
806526380d | ||
|
|
2e63f01438 | ||
|
|
fb97cd080f | ||
|
|
f2d461d019 | ||
|
|
798a0af97d | ||
|
|
3c92a77847 | ||
|
|
b252a6580a | ||
|
|
9ce9933d11 | ||
|
|
f22db6301b | ||
|
|
3b18ab58ed | ||
|
|
bb81daaea4 | ||
|
|
f491d63eb9 | ||
|
|
e923fdc6c7 | ||
|
|
b1ce1297ed | ||
|
|
f4262efaa4 | ||
|
|
a2109a5802 | ||
|
|
00fbb7cd7c | ||
|
|
4078597f7a | ||
|
|
8dc9611a77 | ||
|
|
c1f58c992e | ||
|
|
6631929d6f | ||
|
|
36281db2e3 | ||
|
|
5a8d261943 | ||
|
|
7b83349dc8 | ||
|
|
bc3670df99 | ||
|
|
95613753a9 | ||
|
|
cfa3052a0a | ||
|
|
0bd84f4de5 | ||
|
|
85e51643bf | ||
|
|
6cbdf30580 | ||
|
|
988b10de40 | ||
|
|
a20fbf7e10 | ||
|
|
d2eafd5a4a | ||
|
|
639e96cae8 | ||
|
|
df3dcb60d8 | ||
|
|
09ce3b7861 | ||
|
|
04f0b65480 | ||
|
|
d3e3b82a13 | ||
|
|
769185ee5a | ||
|
|
7b6641e70d | ||
|
|
871f9f0cb2 | ||
|
|
2827d85865 | ||
|
|
693947c064 | ||
|
|
a31c048060 | ||
|
|
1f2be0aeb9 | ||
|
|
92401eb6b1 | ||
|
|
8d2f22d3de | ||
|
|
1d106e45da | ||
|
|
1f07ae9266 | ||
|
|
8399476195 | ||
|
|
7767a06875 | ||
|
|
5bcb7a63bc | ||
|
|
f0b3ed2632 | ||
|
|
185d6fd842 | ||
|
|
fac0479b25 | ||
|
|
b2edda410f | ||
|
|
73465268e2 | ||
|
|
4e449235b2 | ||
|
|
1a7f969b59 | ||
|
|
e7e6ea7dda | ||
|
|
9f522dd727 | ||
|
|
76e8a56cc7 | ||
|
|
012eb74837 | ||
|
|
207a63c0d8 | ||
|
|
bbc381e70c | ||
|
|
de45906860 | ||
|
|
1734ef7c74 | ||
|
|
c4d368eee9 | ||
|
|
f0ff18c792 | ||
|
|
220bda6ef3 | ||
|
|
6209c806a2 | ||
|
|
c85e6fedf2 | ||
|
|
edff370dc8 | ||
|
|
69edbc6ce4 | ||
|
|
76f1d4980c | ||
|
|
39fd7727f4 | ||
|
|
418a2fe808 | ||
|
|
f0c4ba43cb | ||
|
|
5d6208d57a | ||
|
|
bd25881199 | ||
|
|
d029fb5360 | ||
|
|
de3d83e28f | ||
|
|
9bd7fb8681 | ||
|
|
9dbf6e78f1 | ||
|
|
d5e21a2ada | ||
|
|
942df6d001 | ||
|
|
5ffe6025ae | ||
|
|
037b140311 | ||
|
|
5a24cac5a1 | ||
|
|
3444b75345 | ||
|
|
1c05bb17b4 | ||
|
|
f14f97b416 | ||
|
|
50a513d144 | ||
|
|
31101a9818 | ||
|
|
28803bfd2d | ||
|
|
da7a0aa5d6 | ||
|
|
12910fe66d | ||
|
|
193882c4e7 | ||
|
|
968355cfb7 | ||
|
|
c6181c77b1 | ||
|
|
9e61b7f174 | ||
|
|
432ec4bac4 | ||
|
|
64a820a218 | ||
|
|
96a0daa05e | ||
|
|
3571ac2724 | ||
|
|
5ff89c5484 | ||
|
|
4a64b939be | ||
|
|
efbf980924 | ||
|
|
6c6763ebfc | ||
|
|
874847d22f | ||
|
|
937bafc3f0 | ||
|
|
506f12b66e | ||
|
|
98cb52b31b | ||
|
|
03ecce52af | ||
|
|
be63a12607 | ||
|
|
69619665f4 | ||
|
|
b78d915751 | ||
|
|
a12290d343 | ||
|
|
cf993d11dd | ||
|
|
a443393bba | ||
|
|
4d06e1c07e | ||
|
|
31a85e42a6 | ||
|
|
81d9b63dd4 | ||
|
|
2d9ad44a23 | ||
|
|
4fc79adf81 | ||
|
|
6d4ff0d96b | ||
|
|
d01de41e1f | ||
|
|
600934183a | ||
|
|
a2de94fd44 | ||
|
|
4eb89abf3d | ||
|
|
87647c81d3 | ||
|
|
03712ef2a1 | ||
|
|
c64ddd9e18 | ||
|
|
75c3462eae | ||
|
|
9f35dbd5f2 | ||
|
|
4d971d57f7 |
3
.browserslistrc
Normal file
@@ -0,0 +1,3 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not ie > 0
|
||||
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
node_modules
|
||||
docs
|
||||
scripts
|
||||
tests
|
||||
LICENSE
|
||||
README.md
|
||||
*Dockerfile*
|
||||
*docker-compose*
|
||||
36
.eslintrc.js
Normal file
@@ -0,0 +1,36 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: ["plugin:vue/essential", "@vue/prettier"],
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
"max-len": [
|
||||
"error",
|
||||
{
|
||||
ignoreUrls: true,
|
||||
},
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
trailingComma: "es5",
|
||||
printWidth: 80,
|
||||
htmlWhitespaceSensitivity: "ignore",
|
||||
},
|
||||
],
|
||||
},
|
||||
parserOptions: {
|
||||
parser: "babel-eslint",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/__tests__/*.{j,t}s?(x)"],
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
liberapay: owntracks.org
|
||||
17
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Build
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
uses: borales/actions-yarn@v2.0.0
|
||||
with:
|
||||
cmd: install
|
||||
- name: Run production build
|
||||
uses: borales/actions-yarn@v2.0.0
|
||||
with:
|
||||
cmd: build
|
||||
75
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Build Docker images
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *' # everyday at 3am
|
||||
pull_request:
|
||||
branches: master
|
||||
push:
|
||||
branches: master
|
||||
tags:
|
||||
- v*
|
||||
release:
|
||||
types: [published]
|
||||
branches: master
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
run: |
|
||||
DOCKER_IMAGE=owntracks/frontend
|
||||
DOCKER_PLATFORMS=linux/amd64,linux/arm/v7,linux/arm64
|
||||
VERSION=master
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
fi
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
VERSION=nightly
|
||||
fi
|
||||
|
||||
TAGS="--tag ${DOCKER_IMAGE}:${VERSION}"
|
||||
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
TAGS="$TAGS --tag ${DOCKER_IMAGE}:latest"
|
||||
fi
|
||||
|
||||
echo ::set-output name=docker_image::${DOCKER_IMAGE}
|
||||
echo ::set-output name=version::${VERSION}
|
||||
echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} \
|
||||
--build-arg VERSION=${VERSION} \
|
||||
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
|
||||
--build-arg VCS_REF=${GITHUB_SHA::8} \
|
||||
${TAGS} --file ./docker/Dockerfile .
|
||||
- name: Set up Docker Buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v3
|
||||
- name: Docker Buildx (build)
|
||||
run: |
|
||||
docker buildx build --output "type=image,push=false" ${{ steps.prepare.outputs.buildx_args }}
|
||||
- name: Docker Login
|
||||
if: success() && github.event_name != 'pull_request'
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
run: |
|
||||
echo "${DOCKER_PASSWORD}" | docker login --username "${DOCKER_USERNAME}" --password-stdin
|
||||
- name: Docker Buildx (push)
|
||||
if: success() && github.event_name != 'pull_request'
|
||||
run: |
|
||||
docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }}
|
||||
- name: Docker Check Manifest
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
run: |
|
||||
docker run --rm mplatform/mquery ${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.version }}
|
||||
- name: Clear
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
run: |
|
||||
rm -f ${HOME}/.docker/config.json
|
||||
21
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Lint
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
uses: borales/actions-yarn@v2.0.0
|
||||
with:
|
||||
cmd: install
|
||||
- name: Lint JavaScript/Vue files
|
||||
uses: borales/actions-yarn@v2.0.0
|
||||
with:
|
||||
cmd: lint:js
|
||||
- name: Lint Markdown files
|
||||
uses: borales/actions-yarn@v2.0.0
|
||||
with:
|
||||
cmd: lint:md
|
||||
17
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Tests
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
uses: borales/actions-yarn@v2.0.0
|
||||
with:
|
||||
cmd: install
|
||||
- name: Run unit tests
|
||||
uses: borales/actions-yarn@v2.0.0
|
||||
with:
|
||||
cmd: test
|
||||
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
|
||||
*.log
|
||||
|
||||
165
CHANGELOG.md
@@ -1,9 +1,164 @@
|
||||
# 1.1.0 (2019-10-26)
|
||||
# Changelog
|
||||
|
||||
- Add support for Docker. [#7](https://github.com/owntracks/frontend/pull/7), [@sharkoz](https://github.com/sharkoz)
|
||||
- Move project to the OwnTracks organisation on GitHub. [#8](https://github.com/owntracks/frontend/pull/8), [@jpmens](https://github.com/jpmens)
|
||||
- Enable compression in nginx configuration used in Docker image. [#11](https://github.com/owntracks/frontend/pull/11), [@sharkoz](https://github.com/sharkoz)
|
||||
Dates are in UTC.
|
||||
|
||||
# 1.0.0 (2019-06-18)
|
||||
## 2.5.0 (2020-09-07)
|
||||
|
||||
- Add `filters.fitView` config option - this will prevent the map from re-fitting automatically by default when a live location changes ([#41](https://github.com/owntracks/frontend/issues/41))
|
||||
- Show regions for location on popup
|
||||
- Fix vertical offset of non-pin popups
|
||||
- Build Docker images for multiple architectures (linux/amd64, linux/arm/v7, linux/arm64) using GitHub Actions ([#38](https://github.com/owntracks/frontend/pull/38), [@wollew](https://github.com/wollew))
|
||||
- Replace Travis CI with GitHub Actions build/lint/test workflows ([#39](https://github.com/owntracks/frontend/pull/39))
|
||||
- Replace node-sass with sass (dart-sass)
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.4.0 (2020-06-01)
|
||||
|
||||
- Add `filters.minAccuracy` config option - this allows ignoring location points which do
|
||||
not meet the configured accuracy requirement ([#35](https://github.com/owntracks/frontend/issues/35))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.3.1 (2020-05-09)
|
||||
|
||||
- Fix linting issue in `config.md`
|
||||
|
||||
## 2.3.0 (2020-05-09)
|
||||
|
||||
- Add `api.fetchOptions` config option - this allows sending custom HTTP headers or including
|
||||
cookies in the request
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.2.0 (2020-03-18)
|
||||
|
||||
- Improve mobile layout further:
|
||||
- Reduce header paddings
|
||||
- Align buttons/dropdowns
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.1.0 (2020-03-18)
|
||||
|
||||
- Replace default Leaflet marker with a custom one ([#2](https://github.com/owntracks/frontend/issues/2))
|
||||
- Improve verbose mode logging
|
||||
- Improve mobile usability ([#19](https://github.com/owntracks/frontend/issues/19))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.0.0 (2020-03-01)
|
||||
|
||||
Stable release of v2, finally! 🎉
|
||||
|
||||
_This is just a version bump, see all the beta releases below, especially the first one, for a list of changes._
|
||||
|
||||
## 2.0.0-beta.11 (2020-03-01)
|
||||
|
||||
- Add Spanish translations ([#25](https://github.com/owntracks/frontend/pull/25), [@dtorner](https://github.com/dtorner))
|
||||
- Change "distance travelled" label to `title`
|
||||
- Replace map initial center/zoom config with auto fitting ([#23](https://github.com/owntracks/frontend/issues/23))
|
||||
- Enhance code type definitions using TypeScript features ([#20](https://github.com/owntracks/frontend/pull/20))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.0.0-beta.10 (2020-02-07)
|
||||
|
||||
- Add "distance travelled" feature
|
||||
|
||||
## 2.0.0-beta.9 (2020-02-06)
|
||||
|
||||
- Support locale with language and region part (`en-GB`)
|
||||
- Update docs (screenshot, changelog improvements, typo fix)
|
||||
- Add funding information
|
||||
|
||||
## 2.0.0-beta.8 (2020-01-26)
|
||||
|
||||
- Add friendly device name and face images to location history popups
|
||||
- Add missing `alt`/`title` to device face image
|
||||
- Fix all JSDoc `@return` directives to `@returns`
|
||||
- Use computed prop for device name in location popup
|
||||
- Enable ESLint `max-len` rule
|
||||
|
||||
## 2.0.0-beta.7 (2020-01-24)
|
||||
|
||||
This release doesn't really affect end-users but greatly improves the development experience.
|
||||
|
||||
- Add `jsconfig.json`
|
||||
- Set `no-console`/`no-debugger` to `"warn"` in dev mode
|
||||
- Linting and formatting:
|
||||
- Separate npm scripts for linting and formatting
|
||||
- Lint/format Markdown files
|
||||
- Run lint on Travis CI
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.0.0-beta.6 (2019-12-14)
|
||||
|
||||
- Fix heatmap - the upgrade of `vue2-leaflet` from 2.2.1 to 2.3.0 added an `activated` attribute to layers causing the heatmap to not show ([#18](https://github.com/owntracks/frontend/issues/18))
|
||||
|
||||
## 2.0.0-beta.5 (2019-12-14)
|
||||
|
||||
- Add Leaflet popup close button background color transition
|
||||
- Add `$config` Vue instance property
|
||||
- Improve accessibility ([#9](https://github.com/owntracks/frontend/issues/9))
|
||||
- Use configured locale for timestamp formatting
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.0.0-beta.4 (2019-12-14)
|
||||
|
||||
- Add support for time selection ([#10](https://github.com/owntracks/frontend/issues/10))
|
||||
- New date/time picker component is properly translated/localised and keyboard accessible
|
||||
- Config options are now `startDateTime`/`endDateTime` and format of URL parameters changed
|
||||
- Changed default start/end date and time to use local timezone
|
||||
- Fix missing translation of "[date] to [date]"
|
||||
- Update i18n development notes in `README.md`
|
||||
|
||||
## 2.0.0-beta.3 (2019-12-13)
|
||||
|
||||
- Add i18 support (currently English and German, `locale` config option)
|
||||
- Add custom checkbox focus style
|
||||
- Fix layer dropdown issues ([#1](https://github.com/owntracks/frontend/issues/1))
|
||||
- Fix checkbox style issues
|
||||
- Fix hover/focus inconsistencies
|
||||
- Fix Docker image labels
|
||||
- `README.md` enhancements
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.0.0-beta.2 (2019-11-02)
|
||||
|
||||
- Add `onLocationChange.reloadHistory` config option
|
||||
- Add Travis CI config
|
||||
- Fix timezone issues in tests
|
||||
- Fix ESLint errors in production mode
|
||||
- Fix table of content links in config documentation
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.0.0-beta.1 (2019-10-26)
|
||||
|
||||
- Convert codebase to Node.js based development workflow, including:
|
||||
- Package management using yarn
|
||||
- Build step using Webpack and Babel
|
||||
- Usage of Vue single file components
|
||||
- SCSS and PostCSS
|
||||
- ESLint configuration for linting and consistent code style
|
||||
- `package.json` scripts: `serve`, `build`, `lint`, `cors-proxy` and `test`
|
||||
- Design updates, including:
|
||||
- New default primary color (same as OwnTracks Android app)
|
||||
- Improved hover and focus styles as a first attempt to improve accessibility
|
||||
- Improved modals and location popups
|
||||
- Custom checkbox styles
|
||||
- Switch from Font Awesome 4 to Feather Icons
|
||||
- Application now uses Vuex and Vue Router
|
||||
- Add URL query parameters to load and preserve application state: `lat`, `lng`, `zoom`, `start`, `end`, `user`, `device` and `layers`
|
||||
- Add a loading indicator
|
||||
- Add 'download data' modal, currently supporting formatted and minified JSON
|
||||
- Add a verbose mode
|
||||
- Add CORS proxy script to easily use a production instance of the OwnTracks recorder in development
|
||||
- Add unit tests for util and API functions
|
||||
- Add documentation for all public funtions
|
||||
- Add documentation for all configuration options
|
||||
- Add more configuration options, including setting the API base URL ([#4](https://github.com/owntracks/frontend/issues/4)) and hiding the `ping/ping` location ([#12](https://github.com/owntracks/frontend/issues/12))
|
||||
|
||||
## 1.1.0 (2019-10-26)
|
||||
|
||||
- Add support for Docker ([#7](https://github.com/owntracks/frontend/pull/7), [@sharkoz](https://github.com/sharkoz))
|
||||
- Move project to the OwnTracks organisation on GitHub ([#8](https://github.com/owntracks/frontend/pull/8), [@jpmens](https://github.com/jpmens))
|
||||
- Enable compression in nginx configuration used in Docker image ([#11](https://github.com/owntracks/frontend/pull/11), [@sharkoz](https://github.com/sharkoz))
|
||||
|
||||
## 1.0.0 (2019-06-18)
|
||||
|
||||
- Initial release
|
||||
|
||||
11
Dockerfile
@@ -1,11 +0,0 @@
|
||||
FROM nginx:alpine
|
||||
EXPOSE 80
|
||||
ENV LISTEN_PORT=80 \
|
||||
SERVER_HOST=otrecorder \
|
||||
SERVER_PORT=80
|
||||
|
||||
COPY nginx.tmpl /etc/nginx/nginx.tmpl
|
||||
COPY index.html /usr/share/nginx/html
|
||||
COPY static/ /usr/share/nginx/html/static/
|
||||
|
||||
CMD /bin/sh -c "envsubst '\${SERVER_HOST} \${SERVER_PORT} \${LISTEN_PORT}' < /etc/nginx/nginx.tmpl > /etc/nginx/nginx.conf && nginx -g 'daemon off;' || ( env; cat /etc/nginx/nginx.conf )"
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Linus Groh
|
||||
Copyright (c) 2019-2020 Linus Groh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
157
README.md
@@ -1,95 +1,154 @@
|
||||
# OwnTracks UI
|
||||
|
||||
> A modern web interface for OwnTracks made with Vue.js
|
||||
|
||||

|
||||
[](https://hub.docker.com/r/owntracks/frontend)
|
||||
[](https://github.com/owntracks/frontend/actions?query=workflow%3ABuild+branch%3Amaster)
|
||||
[](https://github.com/owntracks/frontend/actions?query=workflow%3ATests+branch%3Amaster)
|
||||
[](https://github.com/owntracks/frontend/actions?query=workflow%3ALint+branch%3Amaster)
|
||||
[](https://github.com/prettier/prettier)
|
||||
[](https://github.com/owntracks/frontend/blob/master/LICENSE)
|
||||
|
||||
<p style="text-align: center;">
|
||||
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/owntracks-ui.png" alt="OwnTracks UI">
|
||||
</p>
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Last known (i.e. live) locations:
|
||||
- Accuracy visualization (circle)
|
||||
- Device friendly name and icon
|
||||
- Detailed information (if available): time, latitude, longitude, height, battery,
|
||||
speed and regions
|
||||
- Location history (data points, line or both)
|
||||
- Location heatmap
|
||||
- Quickly fit all shown objects on the map into view
|
||||
- Display data in a specific date and time range
|
||||
- Filter by user or specific device
|
||||
- Calculation of distance travelled
|
||||
- Download selected location data as JSON
|
||||
- Highly customisable
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual install
|
||||
|
||||
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.
|
||||
|
||||
### Docker
|
||||
|
||||
You can launch directly via Docker run like this:
|
||||
A pre-built Docker image is available on Docker Hub as [`owntracks/frontend`](https://hub.docker.com/r/owntracks/frontend).
|
||||
|
||||
You can start a container directly via `docker run`:
|
||||
|
||||
```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=8083 owntracks/frontend
|
||||
```
|
||||
|
||||
Or you can use `docker-compose` (if you also run the OwnTracks Recorder with the default compose config, and the service is named `otrecorder`):
|
||||
Or you can use `docker-compose` (if you also run the OwnTracks Recorder with the default
|
||||
compose config, and the service is named `otrecorder`):
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
owntracks-ui:
|
||||
image: owntracks/frontend
|
||||
ports:
|
||||
- 80:80
|
||||
volumes:
|
||||
- ./path/to/custom/config.js:/usr/share/nginx/html/config/config.js
|
||||
environment:
|
||||
- SERVER_HOST=otrecorder
|
||||
- SERVER_PORT=8083
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## Features
|
||||
To change the port on which the nginx server will listen on, set the
|
||||
`LISTEN_PORT` enviroment variable - default is 80.
|
||||
|
||||
- Enable or disable multiple layers:
|
||||
To build the image from source replace `image:` with:
|
||||
|
||||
- Last known (i.e. live) locations:
|
||||
```yaml
|
||||
build:
|
||||
context: ./owntracks-frontend
|
||||
dockerfile: docker/Dockerfile
|
||||
```
|
||||
|
||||
- Accuracy visualization (circle)
|
||||
- Device friendly name and icon
|
||||
- Detailed information (if available): time, lat, lon, height, battery and speed
|
||||
(assuming you have this repository cloned to `owntracks-frontend` in the same
|
||||
directory as `docker-compose.yml`)
|
||||
|
||||
- Location history (data points, line or both)
|
||||
- Location heatmap
|
||||
- Button to quickly fit all shown objects on the map into view
|
||||
### Manually
|
||||
|
||||
- Display data in a specific date range
|
||||
- Filter by user and device
|
||||
- Customizable:
|
||||
- Run `yarn install` to install dependencies
|
||||
- Run `yarn build` to compile and minify for production
|
||||
- Copy the content of the `dist/` directory to your webroot
|
||||
|
||||
- UI color
|
||||
- Default start and end date
|
||||
- Map:
|
||||
## Configuration
|
||||
|
||||
- Tile server
|
||||
- Max zoom
|
||||
- Default position and zoom
|
||||
- Heatmap colors, radius and blur
|
||||
It's possible to get started without any configuration change whatsoever, assuming your
|
||||
OwnTracks API is reachable at the root of the same host as the frontend.
|
||||
|
||||
## Screenshots
|
||||
Copy [`public/config/config.example.js`](public/config/config.example.js) to
|
||||
`public/config/config.js` and make changes as you wish.
|
||||
|
||||
_Click to enlarge._
|
||||
See [`docs/config.md`](docs/config.md) for all available options.
|
||||
|
||||
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/live.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/live.png" alt="Live" height="200"></a>
|
||||
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/multiple.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/multiple.png" alt="Multiple" height="200"></a>
|
||||
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/date-selection.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/date-selection.png" alt="Date selection" 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>
|
||||
## Development
|
||||
|
||||
## ToDo
|
||||
- Run `yarn install` to install dependencies
|
||||
- Run `yarn serve` to compile for development and start the hot-reload server
|
||||
- Run `yarn lint:js` to lint JavaScript/Vue files
|
||||
- Run `yarn lint:md` to lint Markdown files
|
||||
- Run `yarn format:js` to format JavaScript/Vue files
|
||||
- Run `yarn format:md` to format Markdown files
|
||||
- Run `yarn test` to run unit tests
|
||||
|
||||
- Node.js based development workflow:
|
||||
### CORS-Proxy
|
||||
|
||||
- Webpack
|
||||
- Vue SFCs
|
||||
- Sass
|
||||
- Dependency management with yarn instead of a local copy or unpkg.com
|
||||
You can use the [`corsProxy.js`](scripts/corsProxy.js) script to use your production
|
||||
instance of OwnTracks for development without making changes to its CORS-Headers:
|
||||
|
||||
- Add documentation, at least for the config file
|
||||
- Download data for selected date range, user and device as JSON
|
||||
```console
|
||||
$ yarn cors-proxy
|
||||
```
|
||||
|
||||
If you have [basic authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme)
|
||||
enabled, create a `.env` file with your credentials:
|
||||
|
||||
```text
|
||||
OT_BASIC_AUTH_USERNAME=user
|
||||
OT_BASIC_AUTH_PASSWORD='P@$$w0rd'
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```console
|
||||
$ env $(cat .env | xargs) yarn cors-proxy
|
||||
```
|
||||
|
||||
The default host and port it binds to is `0.0.0.0:8888`. Change using the `OT_PROXY_HOST`
|
||||
and `OT_PROXY_PORT` environment variables.
|
||||
|
||||
Finally update `api.baseUrl` in your config to `"http://0.0.0.0:8888/https://owntracks.example.com"`.
|
||||
|
||||
### I18n
|
||||
|
||||
This project uses [Vue I18n](https://kazupon.github.io/vue-i18n/). To see missing and
|
||||
unused i18n entries, run:
|
||||
|
||||
```console
|
||||
$ yarn i18n:report
|
||||
```
|
||||
|
||||
To add a new locale, copy `en.json` to `<locale>.json` in [`src/locales`](src/locales)
|
||||
and start translating the individual strings. Make sure to [mention the new locale to the docs](docs/config.md#locale)!
|
||||
|
||||
For a specific example see commit [`b2edda4`](https://github.com/owntracks/frontend/commit/b2edda410f16633aa6fd9cd4e5250f2031536c7d)
|
||||
where German translations were added.
|
||||
|
||||
## 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
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ["@vue/cli-plugin-babel/preset"],
|
||||
};
|
||||
24
docker/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:10 as build
|
||||
WORKDIR /usr/src/app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install
|
||||
COPY . ./
|
||||
RUN yarn build
|
||||
|
||||
FROM nginx:1.17-alpine
|
||||
LABEL version="2.5.0"
|
||||
LABEL description="OwnTracks UI"
|
||||
LABEL maintainer="Linus Groh <mail@linusgroh.de>"
|
||||
ENV LISTEN_PORT=80 \
|
||||
SERVER_HOST=otrecorder \
|
||||
SERVER_PORT=80
|
||||
COPY ./docker/nginx.tmpl /etc/nginx/nginx.tmpl
|
||||
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD /bin/sh -c " \
|
||||
envsubst '\${SERVER_HOST} \${SERVER_PORT} \${LISTEN_PORT}' \
|
||||
< /etc/nginx/nginx.tmpl \
|
||||
> /etc/nginx/nginx.conf \
|
||||
&& nginx -g 'daemon off;' \
|
||||
|| ( env; cat /etc/nginx/nginx.conf ) \
|
||||
"
|
||||
514
docs/config.md
Normal file
@@ -0,0 +1,514 @@
|
||||
# Configuration
|
||||
|
||||
## Overview
|
||||
|
||||
All _custom_ configuation is stored in `window.owntracks.config`,
|
||||
which is a regular JavaScript object - so you can use template strings, spread syntax,
|
||||
comments and other JS features.
|
||||
|
||||
Some of the application state is synced to the URL's query parameters. If a parameter
|
||||
exists in the URL query, it takes precedence over the configured value - otherwise the
|
||||
configured value will be used and appended to the URL query.
|
||||
|
||||
Start with this:
|
||||
|
||||
```js
|
||||
window.owntracks = window.owntracks || {};
|
||||
window.owntracks.config = {};
|
||||
```
|
||||
|
||||
**WARNING: if your configuration contains private data (most commonly your tile server**
|
||||
**access key), make sure to protect access to it properly, e.g. with basic authentication.**
|
||||
|
||||
## Options
|
||||
|
||||
- `api`
|
||||
- [`baseUrl`](#apibaseurl)
|
||||
- [`fetchOptions`](#apifetchoptions)
|
||||
- [`endDateTime`](#enddatetime)
|
||||
- `filters`
|
||||
- [`minAccuracy`](#filtersminaccuracy)
|
||||
- [`ignorePingLocation`](#ignorepinglocation)
|
||||
- [`locale`](#locale)
|
||||
- `map`
|
||||
- [`attribution`](#mapattribution)
|
||||
- [`circle`](#mapcircle)
|
||||
- [`circleMarker`](#mapcirclemarker)
|
||||
- `controls`
|
||||
- `scale`
|
||||
- [`display`](#mapcontrolsscaledisplay)
|
||||
- [`imperial`](#mapcontrolsscaleimperial)
|
||||
- [`maxWidth`](#mapcontrolsscalemaxwidth)
|
||||
- [`metric`](#mapcontrolsscalemetric)
|
||||
- [`position`](#mapcontrolsscaleposition)
|
||||
- `zoom`
|
||||
- [`display`](#mapcontrolszoomdisplay)
|
||||
- [`position`](#mapcontrolszoomposition)
|
||||
- `heatmap`
|
||||
- [`blur`](#mapheatmapblur)
|
||||
- [`gradient`](#mapheatmapgradient)
|
||||
- [`max`](#mapheatmapmax)
|
||||
- [`radius`](#mapheatmapradius)
|
||||
- `layers`
|
||||
- [`heatmap`](#maplayersheatmap)
|
||||
- [`last`](#maplayerslast)
|
||||
- [`line`](#maplayersline)
|
||||
- [`points`](#maplayerspoints)
|
||||
- [`maxNativeZoom`](#mapmaxnativezoom)
|
||||
- [`maxPointDistance`](#mapmaxpointdistance)
|
||||
- [`maxZoom`](#mapmaxzoom)
|
||||
- [`polyline`](#mappolyline)
|
||||
- [`url`](#mapurl)
|
||||
- `onLocationChange`
|
||||
- [`fitView`](#onlocationchangefitview)
|
||||
- [`reloadHistory`](#onlocationchangereloadhistory)
|
||||
- [`primaryColor`](#primarycolor)
|
||||
- [`selectedDevice`](#selecteddevice)
|
||||
- [`selectedUser`](#selecteduser)
|
||||
- [`showDistanceTravelled`](#showdistancetravelled)
|
||||
- [`startDateTime`](#startdatetime)
|
||||
- [`verbose`](#verbose)
|
||||
|
||||
### `api.baseUrl`
|
||||
|
||||
Base URL for the recorder's HTTP and WebSocket API. Keep CORS in mind.
|
||||
|
||||
- Type: [`String`]
|
||||
- Default: current protocol and host
|
||||
- Examples:
|
||||
```js
|
||||
// API requests will be made to https://owntracks.example.com/api/0/...
|
||||
window.owntracks.config = {
|
||||
api: {
|
||||
baseUrl: "https://owntracks.example.com",
|
||||
},
|
||||
};
|
||||
```
|
||||
```js
|
||||
// API requests will be made to https://example.com/owntracks/api/0/...
|
||||
window.owntracks.config = {
|
||||
api: {
|
||||
baseUrl: "https://example.com/owntracks/",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `api.fetchOptions`
|
||||
|
||||
Options for API requests (made with `fetch()`). See [`fetch()` docs on MDN] for details.
|
||||
|
||||
You can use this for example to send custom HTTP headers or to include cookies in the request.
|
||||
|
||||
- Type: [`Object`]
|
||||
- Default: `{}`
|
||||
- Example:
|
||||
```js
|
||||
// Include credentials (e.g. cookies)
|
||||
window.owntracks.config = {
|
||||
api: {
|
||||
fetchOptions: {
|
||||
credentials: "include",
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `endDateTime`
|
||||
|
||||
Initial end date and time (browser timezone) for fetched data.
|
||||
|
||||
- Type: [`Date`]
|
||||
- Default: today, 23:59:59
|
||||
- Example:
|
||||
```js
|
||||
// Data will be fetched up to 1970-01-01
|
||||
window.owntracks.config = {
|
||||
endDateTime: new Date(1970, 1, 1),
|
||||
};
|
||||
```
|
||||
|
||||
### `filters.minAccuracy`
|
||||
|
||||
Minimum accuracy in meters for location points to be rendered & included in the travelled distance.
|
||||
|
||||
This filter is disabled by default as accuracies can vary across devices an locations, but you're
|
||||
encouraged to set it as it can be a simple way to remove outliers and vastly improve the travelled
|
||||
distance calculation.
|
||||
|
||||
- Type: [`Number`] or `null`
|
||||
- Default: `null`
|
||||
- Example:
|
||||
```js
|
||||
// Don't include location points with an accuracy exceeding 100 meters
|
||||
window.owntracks.config = {
|
||||
filters: {
|
||||
minAccuracy: 100,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `ignorePingLocation`
|
||||
|
||||
Remove the `ping/ping` location from the fetched data. This is useful when using the
|
||||
`owntracks/recorder` Docker image which has it [enabled for health checks by default](https://github.com/owntracks/recorder/issues/195#issuecomment-304004436).
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
- Example:
|
||||
```js
|
||||
// Don't ignore ping/ping location. Not sure why you'd do this :)
|
||||
window.owntracks.config = {
|
||||
ignorePingLocation: false,
|
||||
};
|
||||
```
|
||||
|
||||
### `locale`
|
||||
|
||||
The locale to use for the user interface, this affects the language and date/time
|
||||
formats.
|
||||
|
||||
Available languages:
|
||||
|
||||
- `de` (German)
|
||||
- `en` (English)
|
||||
- `es` (Spanish)
|
||||
|
||||
You can use formats like `en-GB`, `en-US`, `de-DE`.
|
||||
|
||||
- Type: [`String`]
|
||||
- Default: `"en"`
|
||||
|
||||
### `map.attribution`
|
||||
|
||||
Attribution for map tiles.
|
||||
|
||||
- Type: [`String`] (may contain HTML)
|
||||
- Default: `"© <a href="https://osm.org/copyright">OpenStreetMap</a> contributors"`
|
||||
- Example:
|
||||
```js
|
||||
// Make sure to add proper attribution!
|
||||
window.owntracks.config = {
|
||||
map: {
|
||||
attribution: "Map tiles © MyTileServerProvider",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `map.circle`
|
||||
|
||||
Location accuracy indicator configuation. `color` and `fillColor` default to
|
||||
`primaryColor` if `null`. See [Vue2Leaflet `l-circle` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-circle/)
|
||||
for all possible values.
|
||||
|
||||
- Type: [`Object`]
|
||||
- Default:
|
||||
```js
|
||||
{
|
||||
color: null,
|
||||
fillColor: null,
|
||||
fillOpacity: 0.2
|
||||
}
|
||||
```
|
||||
|
||||
### `map.circleMarker`
|
||||
|
||||
Location point marker configuation. `color` defaults to `primaryColor` if `null`. See
|
||||
[Vue2Leaflet `l-circle-marker` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-circle-marker/)
|
||||
for all possible values.
|
||||
|
||||
- Type: [`Object`]
|
||||
- Default:
|
||||
```js
|
||||
{
|
||||
color: null,
|
||||
fillColor: "#fff",
|
||||
fillOpacity: 1,
|
||||
radius: 4
|
||||
}
|
||||
```
|
||||
|
||||
### `map.controls.scale.display`
|
||||
|
||||
Whether to show scale control or not.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `map.controls.scale.imperial`
|
||||
|
||||
Whether to show an imperial scale (ft) or not.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `map.controls.scale.maxWidth`
|
||||
|
||||
Maximum width of the scale control in pixels.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `200`
|
||||
|
||||
### `map.controls.scale.metric`
|
||||
|
||||
Whether to show an metric scale (m) or not.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `map.controls.scale.position`
|
||||
|
||||
Scale control position on the map. See [Leaflet control position documentation](https://leafletjs.com/reference-1.5.0.html#control-position)
|
||||
for all possible values.
|
||||
|
||||
- Type: [`String`]
|
||||
- Default: `"bottomleft"`
|
||||
|
||||
### `map.controls.zoom.display`
|
||||
|
||||
Whether to show zoom control or not.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `map.controls.zoom.position`
|
||||
|
||||
Zoom control position on the map. See [Leaflet control position documentation](https://leafletjs.com/reference-1.5.0.html#control-position)
|
||||
for all possible values.
|
||||
|
||||
- Type: [`String`]
|
||||
- Default: `"topleft"`
|
||||
|
||||
### `map.heatmap.blur`
|
||||
|
||||
Heatmap blur radius.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `15`
|
||||
|
||||
### `map.heatmap.gradient`
|
||||
|
||||
Mapping of values between 0 and 1 to different colors. Defaults to [`simpleheat`'s default gradient](https://github.com/mourner/simpleheat/blob/c1998c36fa2f9a31350371fd42ee30eafcc78f9c/simpleheat.js#L22-L28)
|
||||
if `null`.
|
||||
|
||||
- Type: [`Object`] or `null`
|
||||
- Default: `null`
|
||||
|
||||
### `map.heatmap.max`
|
||||
|
||||
Heatmap max data value.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `20`
|
||||
|
||||
### `map.heatmap.radius`
|
||||
|
||||
Heatmap point radius.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `25`
|
||||
|
||||
### `map.layers.heatmap`
|
||||
|
||||
Initial visibility of the heatmap layer.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `false`
|
||||
|
||||
### `map.layers.last`
|
||||
|
||||
Initial visibility of the last locations layer.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `map.layers.line`
|
||||
|
||||
Initial visibility of the line layer.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `map.layers.points`
|
||||
|
||||
Initial visibility of the points layer.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `false`
|
||||
|
||||
### `map.maxNativeZoom`
|
||||
|
||||
This is being used to fetch tiles in different resolutions - set to the highest value
|
||||
the configured tileserver supports.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `19`
|
||||
|
||||
### `map.maxPointDistance`
|
||||
|
||||
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 ceartain distance between
|
||||
two points (which often indicates that they're not related). Set to `null` to disable
|
||||
splitting into separate lines.
|
||||
|
||||
- Type: [`Number`] or `null`
|
||||
- Default: `null`
|
||||
- Example:
|
||||
```js
|
||||
// Don't connect points with a distance of more than 1km
|
||||
window.owntracks.config = {
|
||||
map: {
|
||||
maxPointDistance: 1000,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `map.maxZoom`
|
||||
|
||||
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 [`map.maxNativeZoom`](#map.maxNativeZoom)
|
||||
to disable.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `21`
|
||||
|
||||
### `map.polyline`
|
||||
|
||||
Location point marker configuation. `color` defaults to `primaryColor` if `null`. See
|
||||
[Vue2Leaflet `l-polyline` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-polyline/)
|
||||
for all possible values.
|
||||
|
||||
- Type: [`Object`]
|
||||
- Default:
|
||||
```js
|
||||
{
|
||||
color: null,
|
||||
fillColor: "transparent"
|
||||
}
|
||||
```
|
||||
|
||||
### `map.url`
|
||||
|
||||
Tile server URL. For more information see [Leaflet tile layer documentation](https://leafletjs.com/reference-1.5.0.html#tilelayer-url-template)
|
||||
and [this Wikipedia article](https://en.wikipedia.org/wiki/Tiled_web_map).
|
||||
|
||||
- Type: [`String`]
|
||||
- Default: `"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"`
|
||||
- Example:
|
||||
```js
|
||||
// Use dark HDPI tiles from Mapbox
|
||||
window.owntracks.config = {
|
||||
map: {
|
||||
url:
|
||||
"https://api.mapbox.com/v4/mapbox.dark/{z}/{x}/{y}@2x.png?access_token=xxxxxxxxxxxxxxxx",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `onLocationChange.fitView`
|
||||
|
||||
Whether to re-fit the map's content into view or not when a location update is received.
|
||||
|
||||
This can be useful if you're showing live locations and don't want them to "leave" the map.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `false`
|
||||
|
||||
### `onLocationChange.reloadHistory`
|
||||
|
||||
Whether to reload the location history (of selected date range) or not when a location
|
||||
update is received.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `false`
|
||||
|
||||
### `primaryColor`
|
||||
|
||||
Primary color for the user interface (navigation bar and various map elements).
|
||||
|
||||
- Type: [`String`] ([CSS `<color>`])
|
||||
- Default: `"#3f51b5"` (primary color from the OwnTracks Android app)
|
||||
- Example:
|
||||
```js
|
||||
// Set the UI's primary color to 'rebeccapurple'
|
||||
window.owntracks.config = {
|
||||
primaryColor: "rebeccapurple",
|
||||
};
|
||||
```
|
||||
|
||||
### `selectedDevice`
|
||||
|
||||
Initial selected device. All devices will be shown by default if `null`. Will be ignored
|
||||
if [`selectedUser`](#selectedUser) is `null`.
|
||||
|
||||
Only data for the selected user/device will be fetched, so you can use this to limit the
|
||||
amount of data fetched after page load.
|
||||
|
||||
- Type: [`String`] or `null`
|
||||
- Default: `null`
|
||||
- Example:
|
||||
```js
|
||||
// Select the device 'phone' from user 'foo' by default
|
||||
window.owntracks.config = {
|
||||
selectedUser: "foo",
|
||||
selectedDevice: "phone",
|
||||
};
|
||||
```
|
||||
|
||||
### `selectedUser`
|
||||
|
||||
Initial selected user. All users will be shown by default if `null`.
|
||||
|
||||
Only data for the selected user/device will be fetched, so you can use this to limit the
|
||||
amount of data fetched after page load.
|
||||
|
||||
- Type: [`String`] or `null`
|
||||
- Default: `null`
|
||||
- Example:
|
||||
```js
|
||||
// Select all devices from user 'foo' by default
|
||||
window.owntracks.config = {
|
||||
selectedUser: "foo",
|
||||
};
|
||||
```
|
||||
|
||||
### `showDistanceTravelled`
|
||||
|
||||
Whether to calculate and show the travelled distance of the last fetched data in the
|
||||
header bar. `maxPointDistance` is being takein into account, if a distance between two
|
||||
subsequent points is greater than `maxPointDistance`, it will not contibute to the
|
||||
calculated travelled distance.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `startDateTime`
|
||||
|
||||
Initial start date and time (browser timezone) for fetched data.
|
||||
|
||||
- Type: [`Date`]
|
||||
- Default: one month ago, 00:00:00
|
||||
- Example:
|
||||
```js
|
||||
// Data will be fetched from the first day of the current month
|
||||
const startDateTime = new Date();
|
||||
startDateTime.setHours(0, 0, 0, 0);
|
||||
startDateTime.setDate(1);
|
||||
window.owntracks.config = {
|
||||
startDateTime,
|
||||
};
|
||||
```
|
||||
|
||||
### `verbose`
|
||||
|
||||
Whether to enable verbose mode or not.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `false`
|
||||
|
||||
[`boolean`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean
|
||||
[`date`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
|
||||
[`number`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number
|
||||
[`object`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object
|
||||
[`string`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String
|
||||
[css `<color>`]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
|
||||
[`fetch()` docs on mdn]: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters
|
||||
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 63 KiB |
BIN
docs/images/map-features.png
Normal file
|
After Width: | Height: | Size: 752 KiB |
|
Before Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 653 KiB |
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>
|
||||
23
jest.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: ["js", "jsx", "json", "vue"],
|
||||
transform: {
|
||||
"^.+\\.vue$": "vue-jest",
|
||||
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
|
||||
"jest-transform-stub",
|
||||
"^.+\\.jsx?$": "babel-jest",
|
||||
},
|
||||
transformIgnorePatterns: ["/node_modules/"],
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
},
|
||||
snapshotSerializers: ["jest-serializer-vue"],
|
||||
testMatch: [
|
||||
"**/tests/**/*.test.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)",
|
||||
],
|
||||
testURL: "http://localhost/",
|
||||
watchPlugins: [
|
||||
"jest-watch-typeahead/filename",
|
||||
"jest-watch-typeahead/testname",
|
||||
],
|
||||
setupFiles: ["<rootDir>/tests/setup.js"],
|
||||
};
|
||||
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
65
package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "owntracks-ui",
|
||||
"version": "2.5.0",
|
||||
"author": {
|
||||
"name": "Linus Groh",
|
||||
"email": "mail@linusgroh.de"
|
||||
},
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"cors-proxy": "node scripts/corsProxy.js",
|
||||
"format:js": "vue-cli-service lint",
|
||||
"format:md": "prettier --write '{*.md,docs/**/*.md,src/**/*.md}'",
|
||||
"i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales './src/locales/**/*.json'",
|
||||
"lint:js": "vue-cli-service lint --no-fix",
|
||||
"lint:md": "prettier --check '{*.md,docs/**/*.md,src/**/*.md}'",
|
||||
"test": "vue-cli-service test:unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard-copy": "^3.1.0",
|
||||
"core-js": "^3.6.5",
|
||||
"deepmerge": "^4.2.2",
|
||||
"leaflet": "^1.7.1",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"moment": "^2.27.0",
|
||||
"vue": "^2.6.12",
|
||||
"vue-ctk-date-time-picker": "^2.5.0",
|
||||
"vue-feather-icons": "^5.1.0",
|
||||
"vue-i18n": "^8.21.0",
|
||||
"vue-js-modal": "^1.3.33",
|
||||
"vue-mq": "^1.0.1",
|
||||
"vue-outside-events": "^1.1.3",
|
||||
"vue-router": "^3.4.3",
|
||||
"vue2-leaflet": "^2.5.2",
|
||||
"vuex": "^3.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.5.4",
|
||||
"@vue/cli-plugin-eslint": "~4.5.4",
|
||||
"@vue/cli-plugin-unit-jest": "~4.5.4",
|
||||
"@vue/cli-service": "~4.5.4",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/test-utils": "1.0.5",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.3.0",
|
||||
"cors-anywhere": "^0.4.3",
|
||||
"eslint": "^7.8.1",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"lint-staged": "^10.3.0",
|
||||
"moment-locales-webpack-plugin": "^1.2.0",
|
||||
"prettier": "^2.1.1",
|
||||
"sass": "^1.26.10",
|
||||
"sass-loader": "^10.0.2",
|
||||
"vue-cli-plugin-i18n": "~1.0.1",
|
||||
"vue-template-compiler": "^2.6.12"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/owntracks/frontend.git"
|
||||
}
|
||||
}
|
||||
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
3
public/config/config.example.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Here you can overwite the default configuration values
|
||||
window.owntracks = window.owntracks || {};
|
||||
window.owntracks.config = {};
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
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
@@ -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}`);
|
||||
});
|
||||
97
src/App.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<AppHeader />
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
<DownloadModal />
|
||||
<InformationModal />
|
||||
<LoadingModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import "styles/main";
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapActions } from "vuex";
|
||||
|
||||
import * as types from "@/store/mutation-types";
|
||||
import { log } from "@/logging";
|
||||
import AppHeader from "@/components/AppHeader";
|
||||
import DownloadModal from "@/components/modals/Download";
|
||||
import InformationModal from "@/components/modals/Information";
|
||||
import LoadingModal from "@/components/modals/Loading";
|
||||
|
||||
export default {
|
||||
components: { AppHeader, DownloadModal, InformationModal, LoadingModal },
|
||||
created() {
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-primary",
|
||||
this.$config.primaryColor
|
||||
);
|
||||
this.populateStateFromQuery(this.$route.query);
|
||||
this.loadData();
|
||||
// Update URL query params when relevant values changes
|
||||
this.$store.subscribe((mutation) => {
|
||||
if (
|
||||
[
|
||||
types.SET_SELECTED_USER,
|
||||
types.SET_SELECTED_DEVICE,
|
||||
types.SET_START_DATE_TIME,
|
||||
types.SET_END_DATE_TIME,
|
||||
types.SET_MAP_CENTER,
|
||||
types.SET_MAP_ZOOM,
|
||||
types.SET_MAP_LAYER_VISIBILITY,
|
||||
].includes(mutation.type)
|
||||
) {
|
||||
this.updateUrlQuery();
|
||||
}
|
||||
|
||||
if (mutation.type === types.SET_IS_LOADING) {
|
||||
this.$store.state.isLoading
|
||||
? this.$modal.show("loading")
|
||||
: this.$modal.hide("loading");
|
||||
}
|
||||
});
|
||||
// Initially update URL query params from state
|
||||
this.updateUrlQuery();
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["populateStateFromQuery", "loadData"]),
|
||||
/**
|
||||
* Update all URL query parameters. This is called whenever any
|
||||
* of the relevant values change in the Vuex store.
|
||||
*/
|
||||
updateUrlQuery() {
|
||||
const {
|
||||
map,
|
||||
startDateTime: start,
|
||||
endDateTime: 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,
|
||||
end,
|
||||
...(user !== null && { user }),
|
||||
...(user !== null && device !== null && { device }),
|
||||
...(activeLayers.length > 0 && { layers: activeLayers.join(",") }),
|
||||
};
|
||||
log("STATE", "Updating URL query from state");
|
||||
log(
|
||||
"STATE",
|
||||
JSON.parse(JSON.stringify({ map, start, end, user, device }))
|
||||
);
|
||||
this.$router.replace({ query }).catch(() => {}); // https://github.com/vuejs/vue-router/issues/2872#issuecomment-519073998
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
216
src/api.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import config from "@/config";
|
||||
import { log, logLevels } from "@/logging";
|
||||
import { getApiUrl, getLocationHistoryCount } from "@/util";
|
||||
|
||||
/**
|
||||
* Fetch an API resource.
|
||||
*
|
||||
* @param {String} path API resource path
|
||||
* @param {Object} [params] Query parameters
|
||||
* @returns {Promise} Promise returned by the fetch function
|
||||
*/
|
||||
const fetchApi = (path, params = {}) => {
|
||||
const url = getApiUrl(path);
|
||||
Object.keys(params).forEach((key) =>
|
||||
url.searchParams.append(key, params[key])
|
||||
);
|
||||
log("HTTP", `GET ${url.href}`);
|
||||
return fetch(url.href, config.api.fetchOptions).catch((error) =>
|
||||
log("HTTP", error, logLevels.ERROR)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the recorder's version.
|
||||
*
|
||||
* @returns {String} Version
|
||||
*/
|
||||
export const getVersion = async () => {
|
||||
const response = await fetchApi("/api/0/version");
|
||||
const json = await response.json();
|
||||
const version = json.version;
|
||||
log("API", () => `[getVersion] ${version}`);
|
||||
return version;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
*
|
||||
* @returns {User[]} Array of usernames
|
||||
*/
|
||||
export const getUsers = async () => {
|
||||
const response = await fetchApi("/api/0/list");
|
||||
const json = await response.json();
|
||||
const users = json.results;
|
||||
log("API", () => `[getUsers] Fetched ${users.length} users`);
|
||||
return users;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all devices for the provided users.
|
||||
*
|
||||
* @param {User[]} users Array of usernames
|
||||
* @returns {{User: Device[]}}
|
||||
* Object mapping each username to an array of device names
|
||||
*/
|
||||
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;
|
||||
})
|
||||
);
|
||||
log("API", () => {
|
||||
const devicesCount = Object.keys(devices)
|
||||
.map((user) => devices[user].length)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
`[getDevices] Fetched ${devicesCount} ` +
|
||||
`devices for ${users.length} users`
|
||||
);
|
||||
});
|
||||
return devices;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get last locations for a specific or all user/device.
|
||||
*
|
||||
* @param {User} [user] Get last locations of all devices from this user
|
||||
* @param {Device} [device] Get last location of specific device
|
||||
* @returns {OTLocation[]} Array of last location objects
|
||||
*/
|
||||
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();
|
||||
const lastLocations = json;
|
||||
log(
|
||||
"API",
|
||||
() => `[getLastLocations] Fetched ${lastLocations.length} last locations`
|
||||
);
|
||||
return lastLocations;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the location history of a specific user/device.
|
||||
*
|
||||
* @param {User} user Username
|
||||
* @param {Device} device Device name
|
||||
* @param {String} start Start date and time in UTC
|
||||
* @param {String} end End date and time in UTC
|
||||
* @returns {OTLocation[]} Array of location history objects
|
||||
*/
|
||||
export const getUserDeviceLocationHistory = async (
|
||||
user,
|
||||
device,
|
||||
start,
|
||||
end
|
||||
) => {
|
||||
const response = await fetchApi("/api/0/locations", {
|
||||
from: start,
|
||||
to: end,
|
||||
user,
|
||||
device,
|
||||
format: "json",
|
||||
});
|
||||
const json = await response.json();
|
||||
const userDeviceLocationHistory = json.data;
|
||||
log(
|
||||
"API",
|
||||
() =>
|
||||
`[getUserDeviceLocationHistory] Fetched ` +
|
||||
`${userDeviceLocationHistory.length} locations for ` +
|
||||
`${user}/${device} from ${start} - ${end}`
|
||||
);
|
||||
return userDeviceLocationHistory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the location history of multiple devices.
|
||||
*
|
||||
* @param {{User: Device[]}} devices
|
||||
* Devices of which the history should be fetched
|
||||
* @param {String} start Start date and time in UTC
|
||||
* @param {String} end End date and time in UTC
|
||||
* @returns {LocationHistory} Location history
|
||||
*/
|
||||
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
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
log("API", () => {
|
||||
const locationHistoryCount = getLocationHistoryCount(locationHistory);
|
||||
return (
|
||||
"[getLocationHistory] Fetched " +
|
||||
`${locationHistoryCount} locations in total`
|
||||
);
|
||||
});
|
||||
return locationHistory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Connect to the WebSocket API, reconnect when necessary and handle received
|
||||
* messages.
|
||||
*
|
||||
* @param {WebSocketLocationCallback} [callback] Callback for location messages
|
||||
*/
|
||||
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);
|
||||
log("WS", `Connecting to ${url}`);
|
||||
ws.onopen = () => {
|
||||
log("WS", "Connected");
|
||||
ws.send("LAST");
|
||||
};
|
||||
ws.onclose = (event) => {
|
||||
log(
|
||||
"WS",
|
||||
`Disconnected unexpectedly (reason: ${
|
||||
event.reason || "unknown"
|
||||
}). Reconnecting in one second.`,
|
||||
logLevels.WARNING
|
||||
);
|
||||
setTimeout(connectWebsocket, 1000);
|
||||
};
|
||||
ws.onmessage = async (msg) => {
|
||||
if (msg.data) {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data._type === "location") {
|
||||
log("WS", "Location update received");
|
||||
callback && (await callback());
|
||||
}
|
||||
} catch (err) {
|
||||
if (msg.data !== "LAST") {
|
||||
log("WS", err, logLevels.ERROR);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log("WS", "Ping");
|
||||
}
|
||||
};
|
||||
};
|
||||
259
src/components/AppHeader.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<header :class="$mq === 'sm' ? 'header-sm' : null">
|
||||
<div v-if="$mq === 'sm'" class="header-item">
|
||||
<button
|
||||
class="button button-flat button-icon"
|
||||
@click="showMobileNav = !showMobileNav"
|
||||
>
|
||||
<MenuIcon size="1x" aria-hidden="true" role="img" />
|
||||
</button>
|
||||
</div>
|
||||
<nav
|
||||
v-if="$mq === 'sm' ? showMobileNav : true"
|
||||
class="header-item header-item-grow"
|
||||
:class="$mq === 'sm' ? 'nav-sm' : null"
|
||||
>
|
||||
<div class="nav-item">
|
||||
<CrosshairIcon
|
||||
v-if="$mq === 'sm'"
|
||||
size="1x"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
/>
|
||||
<button
|
||||
class="button button-outline"
|
||||
:title="
|
||||
$t('Automatically center the map view and zoom in to relevant data')
|
||||
"
|
||||
@click="$root.$emit('fitView')"
|
||||
>
|
||||
{{ $t("Fit view") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<LayersIcon size="1x" aria-hidden="true" role="img" />
|
||||
<Dropdown :label="$t('Layer settings')" :title="$t('Show/hide layers')">
|
||||
<label v-for="option in layerSettingsOptions" :key="option.layer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="map.layers[option.layer]"
|
||||
@change="
|
||||
setMapLayerVisibility({
|
||||
layer: option.layer,
|
||||
visibility: $event.target.checked,
|
||||
})
|
||||
"
|
||||
/>
|
||||
{{ option.label }}
|
||||
</label>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<CalendarIcon size="1x" aria-hidden="true" role="img" />
|
||||
<VueCtkDateTimePicker
|
||||
v-model="startDateTime"
|
||||
:format="DATE_TIME_FORMAT"
|
||||
:color="$config.primaryColor"
|
||||
:locale="$config.locale"
|
||||
:max-date="endDateTime"
|
||||
:button-now-translation="$t('Now')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-button button"
|
||||
:title="$t('Select start date')"
|
||||
/>
|
||||
</VueCtkDateTimePicker>
|
||||
<span>{{ $t("to") }}</span>
|
||||
<VueCtkDateTimePicker
|
||||
v-model="endDateTime"
|
||||
:format="DATE_TIME_FORMAT"
|
||||
:color="$config.primaryColor"
|
||||
:locale="$config.locale"
|
||||
:min-date="startDateTime"
|
||||
:button-now-translation="$t('Now')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-button button"
|
||||
:title="$t('Select end date')"
|
||||
/>
|
||||
</VueCtkDateTimePicker>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<UserIcon size="1x" aria-hidden="true" role="img" />
|
||||
<select
|
||||
v-model="selectedUser"
|
||||
class="dropdown-button button"
|
||||
:title="$t('Select user')"
|
||||
>
|
||||
<option :value="null">
|
||||
{{ $t("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" aria-hidden="true" role="img" />
|
||||
<select
|
||||
v-model="selectedDevice"
|
||||
class="dropdown-button button"
|
||||
:title="$t('Select device')"
|
||||
>
|
||||
<option :value="null">
|
||||
{{ $t("Show all") }}
|
||||
</option>
|
||||
<option
|
||||
v-for="device in devices[selectedUser]"
|
||||
:value="device"
|
||||
:key="`${selectedUser}-${device}`"
|
||||
>
|
||||
{{ device }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</nav>
|
||||
<nav class="header-item header-item-right">
|
||||
<div
|
||||
v-if="$config.showDistanceTravelled && distanceTravelled"
|
||||
class="nav-item"
|
||||
:title="$t('Distance travelled')"
|
||||
>
|
||||
{{ humanReadableDistance(distanceTravelled) }}
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<button
|
||||
class="button button-flat button-icon"
|
||||
:title="$t('Download raw data')"
|
||||
@click="$modal.show('download')"
|
||||
>
|
||||
<DownloadIcon
|
||||
size="1x"
|
||||
:aria-label="$t('Download raw data')"
|
||||
role="img"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<button
|
||||
class="button button-flat button-icon"
|
||||
:title="$t('Information')"
|
||||
@click="$modal.show('information')"
|
||||
>
|
||||
<InfoIcon size="1x" :aria-label="$t('Information')" role="img" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from "moment";
|
||||
import { mapActions, mapGetters, mapMutations, mapState } from "vuex";
|
||||
import {
|
||||
CalendarIcon,
|
||||
CrosshairIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
MenuIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
} from "vue-feather-icons";
|
||||
import VueCtkDateTimePicker from "vue-ctk-date-time-picker";
|
||||
import "vue-ctk-date-time-picker/dist/vue-ctk-date-time-picker.css";
|
||||
|
||||
import Dropdown from "@/components/Dropdown";
|
||||
import { DATE_TIME_FORMAT } from "@/constants";
|
||||
import * as types from "@/store/mutation-types";
|
||||
import { humanReadableDistance } from "@/util";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CalendarIcon,
|
||||
CrosshairIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
MenuIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
VueCtkDateTimePicker,
|
||||
Dropdown,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
DATE_TIME_FORMAT,
|
||||
layerSettingsOptions: [
|
||||
{ layer: "last", label: this.$t("Show last known locations") },
|
||||
{ layer: "line", label: this.$t("Show location history (line)") },
|
||||
{ layer: "points", label: this.$t("Show location history (points)") },
|
||||
{ layer: "heatmap", label: this.$t("Show location heatmap") },
|
||||
],
|
||||
showMobileNav: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(["users", "devices", "map", "distanceTravelled"]),
|
||||
selectedUser: {
|
||||
get() {
|
||||
return this.$store.state.selectedUser;
|
||||
},
|
||||
set(value) {
|
||||
this.setSelectedUser(value);
|
||||
},
|
||||
},
|
||||
selectedDevice: {
|
||||
get() {
|
||||
return this.$store.state.selectedDevice;
|
||||
},
|
||||
set(value) {
|
||||
this.setSelectedDevice(value);
|
||||
},
|
||||
},
|
||||
startDateTime: {
|
||||
get() {
|
||||
return moment
|
||||
.utc(this.$store.state.startDateTime, DATE_TIME_FORMAT)
|
||||
.local()
|
||||
.format(DATE_TIME_FORMAT);
|
||||
},
|
||||
set(value) {
|
||||
this.setStartDateTime(
|
||||
moment(value, DATE_TIME_FORMAT).utc().format(DATE_TIME_FORMAT)
|
||||
);
|
||||
},
|
||||
},
|
||||
endDateTime: {
|
||||
get() {
|
||||
return moment
|
||||
.utc(this.$store.state.endDateTime, DATE_TIME_FORMAT)
|
||||
.local()
|
||||
.format(DATE_TIME_FORMAT);
|
||||
},
|
||||
set(value) {
|
||||
this.setEndDateTime(
|
||||
moment(value, DATE_TIME_FORMAT)
|
||||
.set("seconds", 59)
|
||||
.utc()
|
||||
.format(DATE_TIME_FORMAT)
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setMapLayerVisibility: types.SET_MAP_LAYER_VISIBILITY,
|
||||
}),
|
||||
...mapActions([
|
||||
"setSelectedUser",
|
||||
"setSelectedDevice",
|
||||
"setStartDateTime",
|
||||
"setEndDateTime",
|
||||
]),
|
||||
humanReadableDistance,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
38
src/components/Dropdown.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="dropdown" v-focus-outside="hide" v-click-outside="hide">
|
||||
<button class="dropdown-button button" :title="title" @click="toggle">
|
||||
{{ label }}
|
||||
</button>
|
||||
<div v-if="active" class="dropdown-body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
active: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
this.active = !this.active;
|
||||
},
|
||||
hide() {
|
||||
this.active = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
19
src/components/LCustomMarker.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/* eslint-disable max-len */
|
||||
const svg = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="691.429"
|
||||
height="1007.429"
|
||||
viewBox="0 0 182.94 266.549"
|
||||
>
|
||||
<path
|
||||
d="M182.94 91.47c0 50.518-55.748 139.357-91.47 175.079C55.75 230.827 0 141.988 0 91.47 0 40.953 40.953 0 91.47 0c50.518 0 91.47 40.953 91.47 91.47z"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
/* eslint-enable */
|
||||
|
||||
export default L.divIcon({
|
||||
className: "",
|
||||
html: `<span class="pin">${svg}</span>`,
|
||||
});
|
||||
165
src/components/LDeviceLocationPopup.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<LPopup :options="options">
|
||||
<div class="device">{{ deviceName }}</div>
|
||||
<div class="wrapper">
|
||||
<img
|
||||
v-if="face"
|
||||
:src="faceImageDataURI"
|
||||
:alt="$t('Image of {deviceName}', { deviceName })"
|
||||
:title="$t('Image of {deviceName}', { deviceName })"
|
||||
/>
|
||||
<ul class="info-list">
|
||||
<li :title="$t('Timestamp')">
|
||||
<ClockIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ new Date(timestamp * 1000).toLocaleString($config.locale) }}
|
||||
</li>
|
||||
<li :title="$t('Location')">
|
||||
<MapPinIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ lat }}
|
||||
<br />
|
||||
{{ lon }}
|
||||
<br />
|
||||
{{ alt }}m
|
||||
</li>
|
||||
<li v-if="address" :title="$t('Address')">
|
||||
<HomeIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ address }}
|
||||
</li>
|
||||
<li v-if="typeof battery === 'number'" :title="$t('Battery')">
|
||||
<BatteryIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ battery }} %
|
||||
</li>
|
||||
<li v-if="typeof speed === 'number'" :title="$t('Speed')">
|
||||
<ZapIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ speed }} km/h
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="regions.length" class="regions">
|
||||
{{ $t("Regions:") }}
|
||||
{{ regions.join(", ") }}
|
||||
</div>
|
||||
</LPopup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
color: var(--color-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
.wrapper {
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
margin-right: 20px;
|
||||
|
||||
img {
|
||||
align-self: start;
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
.regions {
|
||||
border-top: 1px solid var(--color-separator);
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import {
|
||||
BatteryIcon,
|
||||
ClockIcon,
|
||||
HomeIcon,
|
||||
MapPinIcon,
|
||||
ZapIcon,
|
||||
} from "vue-feather-icons";
|
||||
import { LPopup } from "vue2-leaflet";
|
||||
|
||||
export default {
|
||||
name: "LDeviceLocationPopup",
|
||||
components: {
|
||||
BatteryIcon,
|
||||
ClockIcon,
|
||||
HomeIcon,
|
||||
MapPinIcon,
|
||||
ZapIcon,
|
||||
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,
|
||||
},
|
||||
regions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* Return the face image as a data URI string which can be used for an
|
||||
* image's src attribute.
|
||||
*
|
||||
* @returns {String} base64-encoded face image data URI
|
||||
*/
|
||||
faceImageDataURI() {
|
||||
return `data:image/png;base64,${this.face}`;
|
||||
},
|
||||
/**
|
||||
* Return the device name for displaying with <user identifier>/<device
|
||||
* identifier> as fallback.
|
||||
*
|
||||
* @returns {String} device name for displaying
|
||||
*/
|
||||
deviceName() {
|
||||
return this.name ? this.name : `${this.user}/${this.device}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
138
src/components/LHeatmap.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// See https://github.com/KoRiGaN/Vue2Leaflet/blob/e0cf0f29bc519f0a70f0f1eb6e579f947e7ea4ce/src/utils/utils.js
|
||||
// to understand the `custom` attribute of each prop, how the `set<Prop>`
|
||||
// methods are being used and why `mapObject` has to be named `mapObject`.
|
||||
|
||||
import { findRealParent, propsBinder } from "vue2-leaflet";
|
||||
import L, { DomEvent } from "leaflet";
|
||||
import "leaflet.heat";
|
||||
|
||||
const props = {
|
||||
latLng: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
custom: false,
|
||||
},
|
||||
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: null,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
custom: true,
|
||||
default: true,
|
||||
},
|
||||
activated: {
|
||||
type: Boolean,
|
||||
custom: true,
|
||||
default: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
props,
|
||||
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);
|
||||
DomEvent.on(this.mapObject, this.$listeners);
|
||||
propsBinder(this, this.mapObject, props);
|
||||
this.parentContainer = findRealParent(this.$parent);
|
||||
this.parentContainer.addLayer(this, !this.visible);
|
||||
this.$watch(
|
||||
"latLng",
|
||||
(newVal) => {
|
||||
this.mapObject.setLatLngs(newVal);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.parentContainer.removeLayer(this);
|
||||
},
|
||||
methods: {
|
||||
setMinOpacity(minOpacity) {
|
||||
this.mapObject.setOptions({ minOpacity });
|
||||
},
|
||||
setMaxZoom(maxZoom) {
|
||||
this.mapObject.setOptions({ maxZoom });
|
||||
},
|
||||
setRadius(radius) {
|
||||
this.mapObject.setOptions({ radius });
|
||||
},
|
||||
setBlur(blur) {
|
||||
this.mapObject.setOptions({ blur });
|
||||
},
|
||||
setMax(max) {
|
||||
this.mapObject.setOptions({ max });
|
||||
},
|
||||
setGradient(gradient) {
|
||||
this.mapObject.setOptions({ gradient });
|
||||
},
|
||||
setVisible(newVal, oldVal) {
|
||||
if (newVal === oldVal) return;
|
||||
if (newVal) {
|
||||
this.parentContainer.addLayer(this);
|
||||
} else {
|
||||
this.parentContainer.removeLayer(this);
|
||||
}
|
||||
},
|
||||
addLatLng(value) {
|
||||
this.mapObject.addLatLng(value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
110
src/components/modals/Download.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<modal name="download" adaptive>
|
||||
<pre class="data"><code>{{ data }}</code></pre>
|
||||
<div class="options">
|
||||
<input
|
||||
v-model="options.minifyJson"
|
||||
type="checkbox"
|
||||
id="option-minify-json"
|
||||
/>
|
||||
<label for="option-minify-json">
|
||||
{{ $t("Minify JSON") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="button button-outline button-primary"
|
||||
:title="$t('Copy to clipboard')"
|
||||
@click="copy"
|
||||
>
|
||||
{{ $t("Copy to clipboard") }}
|
||||
</button>
|
||||
<button
|
||||
class="button button-primary"
|
||||
:title="$t('Download')"
|
||||
@click="download"
|
||||
>
|
||||
{{ $t("Download") }}
|
||||
</button>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.options {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
margin-top: 30px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import copy from "clipboard-copy";
|
||||
|
||||
import { download } from "@/util";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
options: {
|
||||
minifyJson: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState([
|
||||
"startDateTime",
|
||||
"endDateTime",
|
||||
"selectedUser",
|
||||
"selectedDevice",
|
||||
"locationHistory",
|
||||
]),
|
||||
data() {
|
||||
return this.locationHistory;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
copy() {
|
||||
const data = JSON.stringify(
|
||||
this.data,
|
||||
null,
|
||||
this.options.minifyJson ? 0 : 2
|
||||
);
|
||||
copy(data);
|
||||
},
|
||||
download() {
|
||||
const data = JSON.stringify(
|
||||
this.data,
|
||||
null,
|
||||
this.options.minifyJson ? 0 : 2
|
||||
);
|
||||
const start = this.startDateTime;
|
||||
const end = this.endDateTime;
|
||||
const user = this.selectedUser ? `_${this.selectedUser}` : "";
|
||||
const device = this.selectedDevice ? `_${this.selectedDevice}` : "";
|
||||
const filename = `data_${start}_${end}${user}${device}.json`;
|
||||
download(data, filename, "application/json");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
51
src/components/modals/Information.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<modal name="information" adaptive>
|
||||
<ul class="info-list">
|
||||
<li>
|
||||
<GithubIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://github.com/owntracks/frontend">owntracks/frontend</a>
|
||||
({{ frontendVersion }})
|
||||
</li>
|
||||
<li>
|
||||
<GithubIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://github.com/owntracks/recorder">owntracks/recorder</a>
|
||||
({{ recorderVersion || "Loading version..." }})
|
||||
</li>
|
||||
<li>
|
||||
<GlobeIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://owntracks.org">
|
||||
{{ $t("OwnTracks website") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<BookIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://owntracks.org/booklet/">
|
||||
{{ $t("OwnTracks documentation") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<TwitterIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://twitter.com/OwnTracks">
|
||||
{{ $t("OwnTracks on Twitter") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import {
|
||||
BookIcon,
|
||||
GithubIcon,
|
||||
GlobeIcon,
|
||||
TwitterIcon,
|
||||
} from "vue-feather-icons";
|
||||
|
||||
export default {
|
||||
components: { BookIcon, GithubIcon, GlobeIcon, TwitterIcon },
|
||||
computed: {
|
||||
...mapState(["frontendVersion", "recorderVersion"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
32
src/components/modals/Loading.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<modal name="loading" :click-to-close="false" adaptive>
|
||||
<LoaderIcon class="loader" size="1.5x" />
|
||||
{{ $t("Loading data, please wait...") }}
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loader {
|
||||
animation: spinning 2s linear infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@keyframes spinning {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { LoaderIcon } from "vue-feather-icons";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LoaderIcon,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
86
src/config.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import deepmerge from "deepmerge";
|
||||
|
||||
const endDateTime = new Date();
|
||||
endDateTime.setHours(23, 59, 59, 0);
|
||||
|
||||
const startDateTime = new Date(endDateTime);
|
||||
startDateTime.setMonth(startDateTime.getMonth() - 1);
|
||||
startDateTime.setHours(0, 0, 0, 0);
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
api: {
|
||||
baseUrl: `${window.location.protocol}//${window.location.host}`,
|
||||
fetchOptions: {},
|
||||
},
|
||||
endDateTime,
|
||||
filters: {
|
||||
minAccuracy: null,
|
||||
},
|
||||
ignorePingLocation: true,
|
||||
locale: "en",
|
||||
map: {
|
||||
attribution:
|
||||
'© <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
|
||||
circle: {
|
||||
color: null,
|
||||
fillColor: null,
|
||||
fillOpacity: 0.2,
|
||||
},
|
||||
circleMarker: {
|
||||
color: null,
|
||||
fillColor: "#fff",
|
||||
fillOpacity: 1,
|
||||
radius: 4,
|
||||
},
|
||||
controls: {
|
||||
scale: {
|
||||
display: true,
|
||||
imperial: true,
|
||||
maxWidth: 200,
|
||||
metric: true,
|
||||
position: "bottomleft",
|
||||
},
|
||||
zoom: {
|
||||
display: true,
|
||||
position: "topleft",
|
||||
},
|
||||
},
|
||||
heatmap: {
|
||||
blur: 15,
|
||||
gradient: null,
|
||||
max: 20,
|
||||
radius: 25,
|
||||
},
|
||||
layers: {
|
||||
heatmap: false,
|
||||
last: true,
|
||||
line: true,
|
||||
points: false,
|
||||
},
|
||||
maxNativeZoom: 19,
|
||||
maxPointDistance: null,
|
||||
maxZoom: 21,
|
||||
polyline: {
|
||||
color: null,
|
||||
fillColor: "transparent",
|
||||
},
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
},
|
||||
onLocationChange: {
|
||||
fitView: false,
|
||||
reloadHistory: false,
|
||||
},
|
||||
primaryColor: "#3f51b5",
|
||||
selectedDevice: null,
|
||||
selectedUser: null,
|
||||
showDistanceTravelled: true,
|
||||
startDateTime,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
// 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 || {});
|
||||
8
src/constants.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// date and time format as expected by the OwnTracks recorder,
|
||||
// using moment.js formatting tokens.
|
||||
// https://momentjs.com/docs/#/displaying/format/
|
||||
export const DATE_TIME_FORMAT = "YYYY-MM-DDTHH:mm:ss";
|
||||
|
||||
// https://en.wikipedia.org/wiki/Earth_radius
|
||||
// Used to calculate the distance between two coordinates.
|
||||
export const EARTH_RADIUS_IN_KM = 6371;
|
||||
23
src/i18n.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import Vue from "vue";
|
||||
import VueI18n from "vue-i18n";
|
||||
|
||||
import config from "@/config";
|
||||
|
||||
Vue.use(VueI18n);
|
||||
|
||||
const locales = require.context("./locales", true, /[A-Za-z0-9-_,\s]+\.json$/i);
|
||||
const messages = {};
|
||||
locales.keys().forEach((key) => {
|
||||
const matched = key.match(/([A-Za-z0-9-_]+)\./i);
|
||||
if (matched && matched.length > 1) {
|
||||
const locale = matched[1];
|
||||
messages[locale] = locales(key);
|
||||
}
|
||||
});
|
||||
|
||||
export default new VueI18n({
|
||||
locale: config.locale.split("-")[0],
|
||||
fallbackLocale: "en",
|
||||
formatFallbackMessages: true,
|
||||
messages,
|
||||
});
|
||||
223
src/index.d.ts
vendored
Normal file
@@ -0,0 +1,223 @@
|
||||
/** Configuration object. */
|
||||
interface Config {
|
||||
api: {
|
||||
baseUrl: string;
|
||||
};
|
||||
endDateTime: Date;
|
||||
ignorePingLocation: boolean;
|
||||
locale: string;
|
||||
map: {
|
||||
attribution: string;
|
||||
circle: {
|
||||
color: OptionalColor;
|
||||
fillColor: OptionalColor;
|
||||
fillOpacity: number;
|
||||
};
|
||||
circleMarker: {
|
||||
color: OptionalColor;
|
||||
fillColor: OptionalColor;
|
||||
fillOpacity: number;
|
||||
radius: number;
|
||||
};
|
||||
controls: {
|
||||
scale: {
|
||||
display: boolean;
|
||||
imperial: boolean;
|
||||
maxWidth: number;
|
||||
metric: boolean;
|
||||
position: string;
|
||||
};
|
||||
zoom: {
|
||||
display: boolean;
|
||||
position: string;
|
||||
};
|
||||
};
|
||||
heatmap: {
|
||||
blur: number;
|
||||
gradient: { number: Color } | null;
|
||||
max: number;
|
||||
radius: number;
|
||||
};
|
||||
layers: {
|
||||
heatmap: boolean;
|
||||
last: boolean;
|
||||
line: boolean;
|
||||
points: boolean;
|
||||
};
|
||||
maxNativeZoom: number;
|
||||
maxPointDistance: number | null;
|
||||
maxZoom: number;
|
||||
polyline: {
|
||||
color: OptionalColor;
|
||||
fillColor: OptionalColor;
|
||||
};
|
||||
url: string;
|
||||
};
|
||||
onLocationChange: {
|
||||
reloadHistory: boolean;
|
||||
};
|
||||
primaryColor: Color;
|
||||
selectedUser: User| null;
|
||||
selectedDevice: Device| null;
|
||||
startDateTime: Date;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
/** Vuex state. */
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
frontendVersion: string;
|
||||
recorderVersion: string;
|
||||
users: User[];
|
||||
devices: { User: Device[] };
|
||||
lastLocations: OTLocation[];
|
||||
locationHistory: LocationHistory;
|
||||
selectedUser: User| null;
|
||||
selectedDevice: Device| null;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
map: {
|
||||
center: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
layers: {
|
||||
heatmap: boolean;
|
||||
last: boolean;
|
||||
line: boolean;
|
||||
points: boolean;
|
||||
};
|
||||
zoom: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A location object as returned by the OwnTracks recorder.
|
||||
* https://owntracks.org/booklet/tech/json/#_typelocation
|
||||
*/
|
||||
interface OTLocation {
|
||||
_http: boolean;
|
||||
/**
|
||||
* In this case always "location"
|
||||
* https://owntracks.org/booklet/tech/json/#types
|
||||
*/
|
||||
_type: string;
|
||||
/** Accuracy in meters */
|
||||
acc?: number;
|
||||
/** Altitude above sea level in meters */
|
||||
alt?: number;
|
||||
/** Device battery level in percent */
|
||||
batt?: number;
|
||||
/**
|
||||
* Battery status (iOS only)
|
||||
*
|
||||
* - `0` = unknown
|
||||
* - `1` = unplugged
|
||||
* - `2` = charging
|
||||
* - `3` = full
|
||||
*/
|
||||
bs?: number;
|
||||
/** Course over ground in degrees (iOS only) */
|
||||
cog?: number;
|
||||
/**
|
||||
* Internet connectivity status (route to host) when the message is created
|
||||
*
|
||||
* - `"w"` = phone is connected to a WiFi connection
|
||||
* - `"o"` = phone is offline
|
||||
* - `"m"` = mobile data
|
||||
*/
|
||||
conn?: string;
|
||||
/** Device name */
|
||||
device?: Device;
|
||||
/** Timestamp in a readable format */
|
||||
disptst: string;
|
||||
/** Base64-encoded face image (device icon) */
|
||||
face?: string;
|
||||
/**
|
||||
* Geohash of the location
|
||||
* https://en.wikipedia.org/wiki/Geohash
|
||||
*/
|
||||
ghash?: string;
|
||||
/** Regions the device is currently in (e.g. `["Home", "Garage"]`). Might be empty. */
|
||||
inregions?: string[];
|
||||
/**
|
||||
* No idea; some kind of timestamp as well - figure it out yourself. :)
|
||||
* https://github.com/owntracks/recorder/blob/df009f791a845012e9cce24923e6203a079ca1ed/storage.c#L659
|
||||
* https://github.com/owntracks/recorder/blob/df009f791a845012e9cce24923e6203a079ca1ed/storage.c#L704
|
||||
*/
|
||||
isorcv?: string;
|
||||
/** ISO 8601 timestamp */
|
||||
isotst?: string;
|
||||
/** Latitude in degrees */
|
||||
lat: number;
|
||||
/** Longitude in degrees */
|
||||
lon: number;
|
||||
/** Friendly device name */
|
||||
name?: string;
|
||||
/**
|
||||
* Trigger for the location report
|
||||
*
|
||||
* - `"p"` = ping issued randomly by background task
|
||||
* - `"c"` = circular region enter/leave event
|
||||
* - `"b"` = beacon region enter/leave event (iOS only)
|
||||
* - `"r"` = response to a reportLocation cmd message
|
||||
* - `"u"` = manual publish requested by the user
|
||||
* - `"t"` = timer based publish in move move (iOS only)
|
||||
* - `"v"` = updated by Settings/Privacy/Locations Services/System Services/Frequent Locations monitoring (iOS only)
|
||||
*/
|
||||
t?: string;
|
||||
/** Tracker ID used to display the initials of a user */
|
||||
tid?: string;
|
||||
/**
|
||||
* Original publish topic
|
||||
* https://owntracks.org/booklet/tech/json/#topics
|
||||
*/
|
||||
topic?: string;
|
||||
/** UNIX epoch timestamp of the location fix in seconds */
|
||||
tst: number;
|
||||
/** User */
|
||||
username?: User;
|
||||
/** Vertical accuracy of the alt element in meters */
|
||||
vac?: number;
|
||||
/** Velocity in km/h */
|
||||
vel?: number;
|
||||
}
|
||||
|
||||
/** URL query parameters (prior to any parsing so it's all strings). */
|
||||
interface QueryParams {
|
||||
/** Map center latitude */
|
||||
lat?: string;
|
||||
/** Map center longitude */
|
||||
lng?: string;
|
||||
/** Start date and time of selected time range */
|
||||
start?: string;
|
||||
/** End date and time of selected time range */
|
||||
end?: string;
|
||||
/** Selected user */
|
||||
user?: string;
|
||||
/** Selected device */
|
||||
device?: string;
|
||||
/** Comma-separated list of active layers */
|
||||
layers?: string;
|
||||
}
|
||||
|
||||
/** Callback for new WebSocket location messages. */
|
||||
interface WebSocketLocationCallback { (): void }
|
||||
|
||||
/** Function for lazy evaluation of log messages. */
|
||||
interface LogMessageFunction { (): string }
|
||||
|
||||
/** A CSS color. */
|
||||
type Color = string;
|
||||
|
||||
/** A CSS color that will use `primaryColor` as fallback. */
|
||||
type OptionalColor = Color | null;
|
||||
|
||||
/** A user's name. */
|
||||
type User = string;
|
||||
|
||||
/** A device's name. */
|
||||
type Device = string;
|
||||
|
||||
/** Multiple location histories mapped to user and devices. */
|
||||
type LocationHistory = { User: { Device: OTLocation[] } };
|
||||
34
src/locales/de.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Kartenansicht automatisch zentrieren und zu relevanten Daten hereinzoomen",
|
||||
"Fit view": "Ansicht anpassen",
|
||||
"Layer settings": "Ebeneneinstellungen",
|
||||
"Show/hide layers": "Ebenen ein-/ausblenden",
|
||||
"Now": "Jetzt",
|
||||
"Select start date": "Startdatum auswählen",
|
||||
"to": "bis",
|
||||
"Select end date": "Enddatum auswählen",
|
||||
"Select user": "Benutzer auswählen",
|
||||
"Show all": "Alle anzeigen",
|
||||
"Select device": "Gerät auswählen",
|
||||
"Distance travelled": "Gereiste Entfernung",
|
||||
"Download raw data": "Rohdaten herunterladen",
|
||||
"Information": "Information",
|
||||
"Show last known locations": "Zeige letzte bekannte Standorte",
|
||||
"Show location history (line)": "Zeige Standortverlauf (Linie)",
|
||||
"Show location history (points)": "Zeige Standortverlauf (Punkte)",
|
||||
"Show location heatmap": "Zeige Standort-Heatmap",
|
||||
"Minify JSON": "JSON minimieren",
|
||||
"Copy to clipboard": "In die Zwischenablage kopieren",
|
||||
"Download": "Herunterladen",
|
||||
"OwnTracks website": "OwnTracks Webseite",
|
||||
"OwnTracks documentation": "OwnTracks Dokumentation",
|
||||
"OwnTracks on Twitter": "OwnTracks auf Twitter",
|
||||
"Loading data, please wait...": "Daten werden geladen, bitte warten...",
|
||||
"Image of {deviceName}": "Bild von {deviceName}",
|
||||
"Timestamp": "Zeitstempel",
|
||||
"Location": "Standort",
|
||||
"Address": "Adresse",
|
||||
"Battery": "Akku",
|
||||
"Speed": "Geschwindigkeit",
|
||||
"Regions:": "Regionen:"
|
||||
}
|
||||
34
src/locales/en.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Automatically center the map view and zoom in to relevant data",
|
||||
"Fit view": "Fit view",
|
||||
"Layer settings": "Layer settings",
|
||||
"Show/hide layers": "Show/hide layers",
|
||||
"Now": "Now",
|
||||
"Select start date": "Select start date",
|
||||
"to": "to",
|
||||
"Select end date": "Select end date",
|
||||
"Select user": "Select user",
|
||||
"Show all": "Show all",
|
||||
"Select device": "Select device",
|
||||
"Distance travelled": "Distance travelled",
|
||||
"Download raw data": "Download raw data",
|
||||
"Information": "Information",
|
||||
"Show last known locations": "Show last known locations",
|
||||
"Show location history (line)": "Show location history (line)",
|
||||
"Show location history (points)": "Show location history (points)",
|
||||
"Show location heatmap": "Show location heatmap",
|
||||
"Minify JSON": "Minify JSON",
|
||||
"Copy to clipboard": "Copy to clipboard",
|
||||
"Download": "Download",
|
||||
"OwnTracks website": "OwnTracks website",
|
||||
"OwnTracks documentation": "OwnTracks documentation",
|
||||
"OwnTracks on Twitter": "OwnTracks on Twitter",
|
||||
"Loading data, please wait...": "Loading data, please wait...",
|
||||
"Image of {deviceName}": "Image of {deviceName}",
|
||||
"Timestamp": "Timestamp",
|
||||
"Location": "Location",
|
||||
"Address": "Address",
|
||||
"Battery": "Battery",
|
||||
"Speed": "Speed",
|
||||
"Regions:": "Regions:"
|
||||
}
|
||||
34
src/locales/es.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Centrar automáticamente el zoom y la vista del mapa a los datos",
|
||||
"Fit view": "Ajustar vista",
|
||||
"Layer settings": "Configuración de capas",
|
||||
"Show/hide layers": "Mostrar/ocultar capas",
|
||||
"Now": "Ahora",
|
||||
"Select start date": "Seleccionar fecha inicio",
|
||||
"to": "hasta",
|
||||
"Select end date": "Seleccionar fecha fin",
|
||||
"Select user": "Seleccionar usuario",
|
||||
"Show all": "Mostrar todos",
|
||||
"Select device": "Seleccionar dispositivo",
|
||||
"Distance travelled": "Distancia recorrida",
|
||||
"Download raw data": "Descargar datos en crudo",
|
||||
"Information": "Información",
|
||||
"Show last known locations": "Mostrar última ubicación conocida",
|
||||
"Show location history (line)": "Mostrar historial (línea)",
|
||||
"Show location history (points)": "Mostrar historial (puntos)",
|
||||
"Show location heatmap": "Mostra mapa de calor",
|
||||
"Minify JSON": "Reducir JSON",
|
||||
"Copy to clipboard": "Copiar al portapapeles",
|
||||
"Download": "Descarga",
|
||||
"OwnTracks website": "OwnTracks - Sitio web",
|
||||
"OwnTracks documentation": "OwnTracks - documentación",
|
||||
"OwnTracks on Twitter": "OwnTracks en Twitter",
|
||||
"Loading data, please wait...": "Cargando datos, por favor, espera...",
|
||||
"Image of {deviceName}": "Imágen de {deviceName}",
|
||||
"Timestamp": "Fecha / Hora",
|
||||
"Location": "Ubicación",
|
||||
"Address": "Dirección",
|
||||
"Battery": "Bateria",
|
||||
"Speed": "Velocidad",
|
||||
"Regions:": "Regiones:"
|
||||
}
|
||||
53
src/logging.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import config from "@/config";
|
||||
|
||||
export const logLevels = {
|
||||
INFO: "INFO",
|
||||
WARNING: "WARNING",
|
||||
ERROR: "ERROR",
|
||||
};
|
||||
|
||||
/* eslint-disable no-console */
|
||||
const logFunctions = {
|
||||
[logLevels.INFO]: console.info,
|
||||
[logLevels.WARNING]: console.warn,
|
||||
[logLevels.ERROR]: console.error,
|
||||
};
|
||||
/* eslint-enable no-console */
|
||||
|
||||
const logColors = {
|
||||
[logLevels.INFO]: "#0d66ba",
|
||||
[logLevels.WARNING]: "#cf8429",
|
||||
[logLevels.ERROR]: "#ad1515",
|
||||
};
|
||||
|
||||
/**
|
||||
* Log a message to the browser's console.
|
||||
*
|
||||
* Convenience wrapper for `console.{info,warn,error}` doing some formatting
|
||||
* and taking the `verbose` config option into account.
|
||||
*
|
||||
* @param {String} label Log message label, useful for filtering
|
||||
* @param {String|LogMessageFunction} message Log message
|
||||
* @param {String} [level] Log level, use `logLevels` constants
|
||||
*/
|
||||
export const log = (label, message, level = logLevels.INFO) => {
|
||||
if (!Object.keys(logLevels).includes(level)) {
|
||||
log("WARNING", `invalid log level: ${level}`, logLevels.WARNING);
|
||||
return;
|
||||
}
|
||||
if (level !== logLevels.ERROR && !config.verbose) {
|
||||
return;
|
||||
}
|
||||
const css = `
|
||||
background: ${logColors[level]};
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
padding: 3px;
|
||||
`;
|
||||
const logFunc = logFunctions[level];
|
||||
logFunc(
|
||||
`%c${label}`,
|
||||
css,
|
||||
typeof message === "function" ? message() : message
|
||||
);
|
||||
};
|
||||
33
src/main.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import Vue from "vue";
|
||||
import VueModal from "vue-js-modal";
|
||||
import VueOutsideEvents from "vue-outside-events";
|
||||
import VueMq from "vue-mq";
|
||||
|
||||
import App from "@/App.vue";
|
||||
import config from "@/config";
|
||||
import { log } from "@/logging";
|
||||
import i18n from "@/i18n";
|
||||
import router from "@/router";
|
||||
import store from "@/store";
|
||||
|
||||
Vue.use(VueModal);
|
||||
Vue.use(VueOutsideEvents);
|
||||
Vue.use(VueMq, {
|
||||
breakpoints: {
|
||||
sm: 1300,
|
||||
lg: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
log("CONFIG", config);
|
||||
|
||||
Vue.prototype.$config = config;
|
||||
|
||||
new Vue({
|
||||
i18n,
|
||||
router,
|
||||
store,
|
||||
render: (h) => h(App),
|
||||
}).$mount("#app");
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
261
src/store/actions.js
Normal file
@@ -0,0 +1,261 @@
|
||||
import * as types from "@/store/mutation-types";
|
||||
import * as api from "@/api";
|
||||
import config from "@/config";
|
||||
import { log } from "@/logging";
|
||||
import {
|
||||
distanceBetweenCoordinates,
|
||||
isIsoDateTime,
|
||||
getLocationHistoryCount,
|
||||
} from "@/util";
|
||||
|
||||
/**
|
||||
* Populate the state from URL query parameters.
|
||||
*
|
||||
* @param {QueryParams} 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 && isIsoDateTime(query.start)) {
|
||||
commit(types.SET_START_DATE_TIME, query.start);
|
||||
}
|
||||
if (query.end && isIsoDateTime(query.end)) {
|
||||
commit(types.SET_END_DATE_TIME, 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");
|
||||
};
|
||||
|
||||
/**
|
||||
* Reload last locations and location history. Will be called when
|
||||
* start date, end date, selected user or selected device changes.
|
||||
*/
|
||||
const reloadData = async ({ dispatch }) => {
|
||||
await dispatch("getLastLocations");
|
||||
await dispatch("getLocationHistory");
|
||||
};
|
||||
|
||||
/**
|
||||
* Connect to WebSocket to receive live location updates. When an update is
|
||||
* received, reload last locations and location history depending on config.
|
||||
*/
|
||||
const connectWebsocket = async ({ dispatch }) => {
|
||||
api.connectWebsocket(async () => {
|
||||
// TODO: keep cards from HTTP API response in the Vuex store so we
|
||||
// can use the data from the WebSocket location update (which does
|
||||
// not contain card information) and don't have to poll the API.
|
||||
await dispatch("getLastLocations");
|
||||
if (config.onLocationChange.reloadHistory) {
|
||||
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 }) => {
|
||||
let lastLocations = await api.getLastLocations(
|
||||
state.selectedUser,
|
||||
state.selectedDevice
|
||||
);
|
||||
if (config.ignorePingLocation) {
|
||||
// Remove ping/ping from the owntracks/recorder Docker image
|
||||
// https://github.com/owntracks/frontend/issues/12
|
||||
lastLocations = lastLocations.filter(
|
||||
(l) => !(l.username === "ping" && l.device === "ping")
|
||||
);
|
||||
}
|
||||
commit(types.SET_LAST_LOCATIONS, lastLocations);
|
||||
};
|
||||
|
||||
const _getDistanceTravelled = (locationHistory) => {
|
||||
const start = Date.now();
|
||||
let distanceTravelled = 0;
|
||||
Object.keys(locationHistory).forEach((user) => {
|
||||
Object.keys(locationHistory[user]).forEach((device) => {
|
||||
let lastLatLng = null;
|
||||
locationHistory[user][device].forEach((location) => {
|
||||
if (
|
||||
config.filters.minAccuracy !== null &&
|
||||
location.acc > config.filters.minAccuracy
|
||||
)
|
||||
return;
|
||||
const latLng = L.latLng(location.lat, location.lon);
|
||||
if (lastLatLng !== null) {
|
||||
const distance = distanceBetweenCoordinates(lastLatLng, latLng);
|
||||
if (
|
||||
typeof config.map.maxPointDistance === "number" &&
|
||||
config.map.maxPointDistance > 0
|
||||
) {
|
||||
if (distance <= config.map.maxPointDistance) {
|
||||
// Part of the current group, add calculated distance to total
|
||||
distanceTravelled += distance;
|
||||
}
|
||||
} else {
|
||||
// If grouping is disabled always add calculated distance to total
|
||||
distanceTravelled += distance;
|
||||
}
|
||||
}
|
||||
lastLatLng = latLng;
|
||||
});
|
||||
});
|
||||
});
|
||||
const end = Date.now();
|
||||
log("DISTANCE", () => {
|
||||
const locationHistoryCount = getLocationHistoryCount(locationHistory);
|
||||
const duration = (end - start) / 1000;
|
||||
return (
|
||||
`[_getDistanceTravelled] Took ${duration} seconds to ` +
|
||||
`calculate distance of ${locationHistoryCount} locations`
|
||||
);
|
||||
});
|
||||
return distanceTravelled;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load location history of all devices, in the selected date range.
|
||||
*/
|
||||
const getLocationHistory = async ({ commit, state }) => {
|
||||
commit(types.SET_IS_LOADING, true);
|
||||
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;
|
||||
}
|
||||
const locationHistory = await api.getLocationHistory(
|
||||
devices,
|
||||
state.startDateTime,
|
||||
state.endDateTime
|
||||
);
|
||||
commit(types.SET_IS_LOADING, false);
|
||||
commit(types.SET_LOCATION_HISTORY, locationHistory);
|
||||
if (config.showDistanceTravelled) {
|
||||
commit(
|
||||
types.SET_DISTANCE_TRAVELLED,
|
||||
_getDistanceTravelled(locationHistory)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 {User} user Name of the new selected user
|
||||
*/
|
||||
const setSelectedUser = async ({ commit, dispatch }, user) => {
|
||||
commit(types.SET_SELECTED_DEVICE, null);
|
||||
commit(types.SET_SELECTED_USER, user);
|
||||
await dispatch("reloadData");
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected device and reload the location history.
|
||||
*
|
||||
* @param {Device} device Name of the new selected device
|
||||
*/
|
||||
const setSelectedDevice = async ({ commit, dispatch }, device) => {
|
||||
commit(types.SET_SELECTED_DEVICE, device);
|
||||
await dispatch("reloadData");
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the start date and time for loading data and reload the location history.
|
||||
*
|
||||
* @param {String} startDateTime Start date and time in UTC for loading data
|
||||
*/
|
||||
const setStartDateTime = async ({ commit, dispatch }, startDateTime) => {
|
||||
commit(types.SET_START_DATE_TIME, startDateTime);
|
||||
await dispatch("reloadData");
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the end date and time for loading data and reload the location history.
|
||||
*
|
||||
* @param {String} endDateTime End date and time in UTC for loading data
|
||||
*/
|
||||
const setEndDateTime = async ({ commit, dispatch }, endDateTime) => {
|
||||
commit(types.SET_END_DATE_TIME, endDateTime);
|
||||
await dispatch("reloadData");
|
||||
};
|
||||
|
||||
export default {
|
||||
populateStateFromQuery,
|
||||
loadData,
|
||||
reloadData,
|
||||
connectWebsocket,
|
||||
getUsers,
|
||||
getDevices,
|
||||
getLastLocations,
|
||||
getLocationHistory,
|
||||
getRecorderVersion,
|
||||
setSelectedUser,
|
||||
setSelectedDevice,
|
||||
setStartDateTime,
|
||||
setEndDateTime,
|
||||
};
|
||||
99
src/store/getters.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import L from "leaflet";
|
||||
|
||||
import config from "@/config";
|
||||
import { distanceBetweenCoordinates } from "@/util";
|
||||
|
||||
/**
|
||||
* Apply filters to the selected users' and devices' location histories.
|
||||
*
|
||||
* @param {State} state
|
||||
* @param {LocationHistory} state.locationHistory
|
||||
* Location history of selected users and devices
|
||||
* @returns {LocationHistory} Filtered location history
|
||||
*/
|
||||
const filteredLocationHistory = (state) => {
|
||||
const locationHistory = {};
|
||||
Object.keys(state.locationHistory).forEach((user) => {
|
||||
locationHistory[user] = {};
|
||||
Object.keys(state.locationHistory[user]).forEach((device) => {
|
||||
locationHistory[user][device] = [];
|
||||
state.locationHistory[user][device].forEach((location) => {
|
||||
if (
|
||||
config.filters.minAccuracy !== null &&
|
||||
location.acc > config.filters.minAccuracy
|
||||
)
|
||||
return;
|
||||
locationHistory[user][device].push(location);
|
||||
});
|
||||
});
|
||||
});
|
||||
return locationHistory;
|
||||
};
|
||||
|
||||
/**
|
||||
* From the selected users' and devices' location histories, create an
|
||||
* array of all coordinates.
|
||||
*
|
||||
* @param {State} state
|
||||
* @returns {L.LatLng[]} All coordinates
|
||||
*/
|
||||
const filteredLocationHistoryLatLngs = (state) => {
|
||||
const latLngs = [];
|
||||
const locationHistory = filteredLocationHistory(state);
|
||||
Object.keys(locationHistory).forEach((user) => {
|
||||
Object.keys(locationHistory[user]).forEach((device) => {
|
||||
locationHistory[user][device].forEach((location) => {
|
||||
latLngs.push(L.latLng(location.lat, location.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 {State} state
|
||||
* @returns {L.LatLng[][]} Groups of coherent coordinates
|
||||
*/
|
||||
const filteredLocationHistoryLatLngGroups = (state) => {
|
||||
const groups = [];
|
||||
const locationHistory = filteredLocationHistory(state);
|
||||
Object.keys(locationHistory).forEach((user) => {
|
||||
Object.keys(locationHistory[user]).forEach((device) => {
|
||||
let latLngs = [];
|
||||
locationHistory[user][device].forEach((location) => {
|
||||
const latLng = L.latLng(location.lat, location.lon);
|
||||
// Skip if group splitting is disabled or this is the first
|
||||
// coordinate in the current group
|
||||
if (
|
||||
typeof config.map.maxPointDistance === "number" &&
|
||||
config.map.maxPointDistance > 0 &&
|
||||
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;
|
||||
};
|
||||
|
||||
export default {
|
||||
filteredLocationHistory,
|
||||
filteredLocationHistoryLatLngs,
|
||||
filteredLocationHistoryLatLngGroups,
|
||||
};
|
||||
38
src/store/index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
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: {
|
||||
isLoading: false,
|
||||
frontendVersion: process.env.PACKAGE_VERSION,
|
||||
recorderVersion: "",
|
||||
users: [],
|
||||
devices: {},
|
||||
lastLocations: [],
|
||||
locationHistory: {},
|
||||
selectedUser: config.selectedUser,
|
||||
selectedDevice: config.selectedUser !== null ? config.selectedDevice : null,
|
||||
// Convert to UTC and get rid of milliseconds
|
||||
startDateTime: config.startDateTime.toISOString().slice(0, 19),
|
||||
endDateTime: config.endDateTime.toISOString().slice(0, 19),
|
||||
map: {
|
||||
center: {
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
},
|
||||
zoom: 19,
|
||||
layers: config.map.layers,
|
||||
},
|
||||
distanceTravelled: null,
|
||||
},
|
||||
getters,
|
||||
mutations,
|
||||
actions,
|
||||
});
|
||||
14
src/store/mutation-types.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export const SET_IS_LOADING = "SET_IS_LOADING";
|
||||
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_TIME = "SET_START_DATE_TIME";
|
||||
export const SET_END_DATE_TIME = "SET_END_DATE_TIME";
|
||||
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_DISTANCE_TRAVELLED = "SET_DISTANCE_TRAVELLED";
|
||||
46
src/store/mutations.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as types from "@/store/mutation-types";
|
||||
|
||||
export default {
|
||||
[types.SET_IS_LOADING](state, isLoading) {
|
||||
state.isLoading = isLoading;
|
||||
},
|
||||
[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_TIME](state, startDateTime) {
|
||||
state.startDateTime = startDateTime;
|
||||
},
|
||||
[types.SET_END_DATE_TIME](state, endDateTime) {
|
||||
state.endDateTime = endDateTime;
|
||||
},
|
||||
[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_DISTANCE_TRAVELLED](state, distanceTravelled) {
|
||||
state.distanceTravelled = distanceTravelled;
|
||||
},
|
||||
};
|
||||
354
src/styles/_base.scss
Normal file
@@ -0,0 +1,354 @@
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-text: #333;
|
||||
--color-background: #fff;
|
||||
--color-primary: #3f51b5;
|
||||
--color-primary-text: #fff;
|
||||
--color-separator: #ddd;
|
||||
--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");
|
||||
--pin-width: 32px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Noto Sans", sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: inside;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
border: 0; // Remove the unchecked checkbox outline in Safari on iOS
|
||||
border-radius: 4px; // Round the focus box-shadow
|
||||
cursor: pointer;
|
||||
margin-right: 3px;
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&::before {
|
||||
border: 2px solid var(--color-primary);
|
||||
border-radius: 4px;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&:checked::before {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
&:checked::after {
|
||||
border-bottom: 2px solid var(--color-primary-text);
|
||||
border-right: 2px solid var(--color-primary-text);
|
||||
content: "";
|
||||
display: inline-block;
|
||||
height: 10px;
|
||||
left: 7px;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
transform: rotate(45deg);
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
& + label {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #000;
|
||||
border-radius: 3px;
|
||||
color: #ddd;
|
||||
display: block;
|
||||
font-family: Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console",
|
||||
"Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono",
|
||||
"Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
display: block;
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
// Only select immediate child as the datepicker contains a <header> as well
|
||||
> header {
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
white-space: nowrap;
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary);
|
||||
|
||||
&.header-sm {
|
||||
padding: 10px;
|
||||
|
||||
.header-item:not(.nav-sm) .nav-item:not(:first-child) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
&-grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-right {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
|
||||
.feather {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
&:not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
> span {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.feather {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.button-icon .feather {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.nav-sm {
|
||||
background: var(--color-primary);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.2);
|
||||
bottom: 0;
|
||||
display: block;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
padding: 20px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 56px;
|
||||
z-index: 1;
|
||||
|
||||
.nav-item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 400px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
> .button,
|
||||
> .dropdown,
|
||||
> .date-time-picker {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> .dropdown .dropdown-button,
|
||||
> .date-time-picker .dropdown-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> .date-time-picker {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
> span {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// THIS IS TERRIBLE (but it works for now)
|
||||
> :not(:nth-child(1)):not(:nth-child(2)) {
|
||||
display: block;
|
||||
margin-left: 30px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ul.info-list {
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
// https://stackoverflow.com/a/17158366/5952681
|
||||
margin-left: 25px;
|
||||
text-indent: -25px;
|
||||
|
||||
.feather {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
& + li {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
padding: 8px 16px;
|
||||
text-overflow: ellipsis;
|
||||
transition: box-shadow 0.2s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&:focus::-moz-focus-inner {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&.button-primary {
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
&.button-outline {
|
||||
border: 1px solid var(--color-background);
|
||||
color: var(--color-primary-text);
|
||||
background: transparent;
|
||||
|
||||
&.button-primary {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
&.button-flat {
|
||||
color: var(--color-primary-text);
|
||||
background: transparent;
|
||||
|
||||
&.button-primary {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
&.button-outline,
|
||||
&.button-flat {
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&.button-icon {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Not nested so it works as the button alone
|
||||
.dropdown-button {
|
||||
appearance: none;
|
||||
background-image: var(--dropdown-arrow);
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: right;
|
||||
background-position-y: center;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.dropdown-body {
|
||||
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: 1;
|
||||
|
||||
&::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;
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: 8px 15px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feather {
|
||||
vertical-align: middle;
|
||||
}
|
||||
10
src/styles/_datepicker.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.date-time-picker {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
|
||||
.datepicker {
|
||||
box-shadow: none !important;
|
||||
filter: var(--drop-shadow);
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
84
src/styles/_map.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
.leaflet-container {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
|
||||
.leaflet-popup {
|
||||
filter: var(--drop-shadow);
|
||||
|
||||
&--for-pin {
|
||||
margin-bottom: calc(var(--pin-width) * 1.5 + 20px);
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
a.leaflet-popup-close-button {
|
||||
color: inherit;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 10px;
|
||||
margin-top: 15px;
|
||||
margin-right: 15px;
|
||||
border-radius: 100px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.leaflet-control-container .leaflet-control-attribution {
|
||||
background: var(--color-background);
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-marker-icon {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
margin: 0 !important;
|
||||
|
||||
.pin {
|
||||
display: block;
|
||||
margin-left: calc(-1 * var(--pin-width) / 2);
|
||||
margin-top: calc(-1 * var(--pin-width) * 1.5);
|
||||
position: relative;
|
||||
width: var(--pin-width);
|
||||
|
||||
&::before {
|
||||
background: var(--color-background);
|
||||
border-radius: 100%;
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: calc(var(--pin-width) / 2);
|
||||
height: calc(var(--pin-width) / 2);
|
||||
top: calc(var(--pin-width) / 4);
|
||||
left: calc(var(--pin-width) / 4);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
|
||||
path {
|
||||
fill: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/styles/_modal.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
.v--modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
|
||||
.v--modal-background-click {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.v--modal-box.v--modal {
|
||||
top: initial !important;
|
||||
left: initial !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
overflow: auto;
|
||||
padding: 30px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
src/styles/main.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "base";
|
||||
@import "datepicker";
|
||||
@import "map";
|
||||
@import "modal";
|
||||
118
src/util.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import moment from "moment";
|
||||
|
||||
import config from "@/config";
|
||||
import { DATE_TIME_FORMAT, EARTH_RADIUS_IN_KM } from "@/constants";
|
||||
|
||||
/**
|
||||
* Get a complete URL for any API resource, taking the
|
||||
* base URL configuration into account.
|
||||
*
|
||||
* @param {String} path Path to the API resource
|
||||
* @returns {URL} Final API URL
|
||||
*/
|
||||
export const getApiUrl = (path) => {
|
||||
const normalizedBaseUrl = config.api.baseUrl.endsWith("/")
|
||||
? config.api.baseUrl.slice(1)
|
||||
: config.api.baseUrl;
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return new URL(`${normalizedBaseUrl}${normalizedPath}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given string is an ISO 8601 YYYY-MM-DDTHH:MM:SS datetime.
|
||||
*
|
||||
* @param {String} s Input value to be tested
|
||||
* @returns {Boolean} Whether the input matches the expected format
|
||||
*/
|
||||
export const isIsoDateTime = (s) => moment(s, DATE_TIME_FORMAT, true).isValid();
|
||||
|
||||
/**
|
||||
* Convert degrees to radians.
|
||||
*
|
||||
* @param {Number} degrees Angle in degrees
|
||||
* @returns {Number} Angle in radians
|
||||
*/
|
||||
export const degreesToRadians = (degrees) => (degrees * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* Calculate the distance between two coordinates. Uses the haversine formula,
|
||||
* which is not 100% accurate - but that's not the goal here.
|
||||
*
|
||||
* https://en.wikipedia.org/wiki/Haversine_formula
|
||||
*
|
||||
* @param {Coordinate} c1 First coordinate
|
||||
* @param {Coordinate} c2 Second coordinate
|
||||
* @returns {Number} Distance in meters
|
||||
*/
|
||||
export const distanceBetweenCoordinates = (c1, c2) => {
|
||||
const r = EARTH_RADIUS_IN_KM * 1000;
|
||||
const phi1 = degreesToRadians(c1.lat);
|
||||
const phi2 = degreesToRadians(c2.lat);
|
||||
const lambda1 = degreesToRadians(c1.lng);
|
||||
const lambda2 = degreesToRadians(c2.lng);
|
||||
const d =
|
||||
2 *
|
||||
r *
|
||||
Math.asin(
|
||||
Math.sqrt(
|
||||
Math.sin((phi2 - phi1) / 2) ** 2 +
|
||||
Math.cos(phi1) *
|
||||
Math.cos(phi2) *
|
||||
Math.sin((lambda2 - lambda1) / 2) ** 2
|
||||
)
|
||||
);
|
||||
return d;
|
||||
};
|
||||
|
||||
/**
|
||||
* Let the user download a string as file.
|
||||
*
|
||||
* @param {String} text Content of the file
|
||||
* @param {String} filename Suggested filename for the browser
|
||||
* @param {String} [mimeType] Content mime type
|
||||
*/
|
||||
export const download = (text, filename, mimeType = "text/plain") => {
|
||||
const dataUrl = `data:${mimeType},${encodeURIComponent(text)}`;
|
||||
const element = document.createElement("a");
|
||||
element.href = dataUrl;
|
||||
element.download = filename;
|
||||
element.style.display = "none";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a distance in meters into a human-readable string with unit.
|
||||
*
|
||||
* This only supports m / km for now, but could read a config option and return
|
||||
* ft / mi.
|
||||
*
|
||||
* @param {Number} distance Distance in meters
|
||||
* @returns {String} Formatted string including unit
|
||||
*/
|
||||
export const humanReadableDistance = (distance) => {
|
||||
let unit = "m";
|
||||
if (Math.abs(distance) >= 1000) {
|
||||
distance = distance / 1000;
|
||||
unit = "km";
|
||||
}
|
||||
return `${distance.toLocaleString(config.locale, {
|
||||
maximumFractionDigits: 1,
|
||||
})} ${unit}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the total number of locations from a nested location history.
|
||||
*
|
||||
* @param {LocationHistory} locationHistory Location history
|
||||
* @returns {Number} Total number of locations
|
||||
*/
|
||||
export const getLocationHistoryCount = (locationHistory) =>
|
||||
Object.keys(locationHistory)
|
||||
.map((user) =>
|
||||
Object.keys(locationHistory[user])
|
||||
.map((device) => locationHistory[user][device].length)
|
||||
.reduce((a, b) => a + b, 0)
|
||||
)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
244
src/views/Map.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<LMap
|
||||
ref="map"
|
||||
:center="map.center"
|
||||
:zoom="map.zoom"
|
||||
:options="{ zoomControl: false }"
|
||||
@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="[l.lat, l.lon]"
|
||||
:radius="l.acc"
|
||||
v-bind="circle"
|
||||
/>
|
||||
|
||||
<LMarker
|
||||
v-for="l in lastLocations"
|
||||
:key="`${l.topic}-marker`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
:icon="markerIcon"
|
||||
>
|
||||
<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"
|
||||
:regions="l.inregions"
|
||||
:options="{ className: 'leaflet-popup--for-pin' }"
|
||||
/>
|
||||
</LMarker>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.line">
|
||||
<LPolyline
|
||||
v-for="(group, i) in filteredLocationHistoryLatLngGroups"
|
||||
:key="i"
|
||||
:lat-lngs="group"
|
||||
v-bind="polyline"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.points">
|
||||
<template v-for="(userDevices, user) in filteredLocationHistory">
|
||||
<template v-for="(deviceLocations, device) in userDevices">
|
||||
<LCircleMarker
|
||||
v-for="(l, n) in deviceLocationsWithNameAndFace(
|
||||
user,
|
||||
device,
|
||||
deviceLocations
|
||||
)"
|
||||
:key="`${user}-${device}-${n}`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
v-bind="circleMarker"
|
||||
>
|
||||
<LDeviceLocationPopup
|
||||
:user="user"
|
||||
:device="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"
|
||||
:regions="l.inregions"
|
||||
></LDeviceLocationPopup>
|
||||
</LCircleMarker>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.heatmap">
|
||||
<LHeatmap
|
||||
v-if="filteredLocationHistoryLatLngs.length"
|
||||
:lat-lng="filteredLocationHistoryLatLngs"
|
||||
: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 {
|
||||
LMap,
|
||||
LTileLayer,
|
||||
LControlScale,
|
||||
LControlZoom,
|
||||
LMarker,
|
||||
LCircleMarker,
|
||||
LCircle,
|
||||
LPolyline,
|
||||
} from "vue2-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import * as types from "@/store/mutation-types";
|
||||
import LCustomMarker from "@/components/LCustomMarker";
|
||||
import LHeatmap from "@/components/LHeatmap";
|
||||
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LMap,
|
||||
LTileLayer,
|
||||
LControlScale,
|
||||
LControlZoom,
|
||||
LMarker,
|
||||
LCircleMarker,
|
||||
LCircle,
|
||||
LPolyline,
|
||||
LDeviceLocationPopup,
|
||||
LHeatmap,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
attribution: this.$config.map.attribution,
|
||||
center: this.$store.state.map.center,
|
||||
controls: this.$config.map.controls,
|
||||
heatmap: this.$config.map.heatmap,
|
||||
markerIcon: LCustomMarker,
|
||||
maxZoom: this.$config.map.maxZoom,
|
||||
maxNativeZoom: this.$config.map.maxNativeZoom,
|
||||
url: this.$config.map.url,
|
||||
zoom: this.$store.state.map.zoom,
|
||||
circle: {
|
||||
...this.$config.map.circle,
|
||||
color: this.$config.map.circle.color || this.$config.primaryColor,
|
||||
fillColor:
|
||||
this.$config.map.circle.fillColor || this.$config.primaryColor,
|
||||
},
|
||||
circleMarker: {
|
||||
...this.$config.map.circleMarker,
|
||||
color: this.$config.map.circleMarker.color || this.$config.primaryColor,
|
||||
},
|
||||
polyline: {
|
||||
...this.$config.map.polyline,
|
||||
color: this.$config.map.polyline.color || this.$config.primaryColor,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$root.$on("fitView", () => {
|
||||
this.fitView();
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
"filteredLocationHistory",
|
||||
"filteredLocationHistoryLatLngs",
|
||||
"filteredLocationHistoryLatLngGroups",
|
||||
]),
|
||||
...mapState(["lastLocations", "map"]),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setMapCenter: types.SET_MAP_CENTER,
|
||||
setMapZoom: types.SET_MAP_ZOOM,
|
||||
}),
|
||||
/**
|
||||
* Fit all objects on the map into view.
|
||||
*/
|
||||
fitView() {
|
||||
if (
|
||||
(this.map.layers.line ||
|
||||
this.map.layers.points ||
|
||||
this.map.layers.heatmap) &&
|
||||
this.filteredLocationHistoryLatLngs.length > 0
|
||||
) {
|
||||
this.$refs.map.mapObject.fitBounds(
|
||||
new L.LatLngBounds(this.filteredLocationHistoryLatLngs)
|
||||
);
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Find a the last location object for a user/device combination from the
|
||||
* local cache and backfill name and face attributes to each item from the
|
||||
* passed array of location objects.
|
||||
*
|
||||
* @param {User} user Username
|
||||
* @param {Device} device Device name
|
||||
* @param {OTLocation[]} deviceLocations Device name
|
||||
* @returns {OTLocation[]} Updated locations
|
||||
*/
|
||||
deviceLocationsWithNameAndFace(user, device, deviceLocations) {
|
||||
const lastLocation = this.lastLocations.find(
|
||||
(l) => l.username === user && l.device === device
|
||||
);
|
||||
if (!lastLocation) {
|
||||
return deviceLocations;
|
||||
}
|
||||
return deviceLocations.map((l) => ({
|
||||
...l,
|
||||
name: lastLocation.name,
|
||||
face: lastLocation.face,
|
||||
}));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
lastLocations() {
|
||||
if (this.$config.onLocationChange.fitView) {
|
||||
this.fitView();
|
||||
}
|
||||
},
|
||||
filteredLocationHistory() {
|
||||
this.fitView();
|
||||
},
|
||||
},
|
||||
};
|
||||
</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
@@ -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();
|
||||
},
|
||||
});
|
||||
})();
|
||||
270
static/style.css
@@ -1,270 +0,0 @@
|
||||
* {
|
||||
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;
|
||||
/* https://github.com/linusg/owntracks-ui/issues/6 */
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.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 {
|
||||
/* https://github.com/linusg/owntracks-ui/issues/6 */
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
5
tests/.eslintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
};
|
||||
217
tests/api.test.js
Normal file
@@ -0,0 +1,217 @@
|
||||
import * as api from "@/api";
|
||||
|
||||
describe("API", () => {
|
||||
beforeEach(() => {
|
||||
fetch.resetMocks();
|
||||
});
|
||||
|
||||
test("getVersion", async () => {
|
||||
fetch.mockResponse(JSON.stringify({ version: "1.2.3" }));
|
||||
|
||||
const version = await api.getVersion();
|
||||
expect(version).toBe("1.2.3");
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual("http://localhost/api/0/version");
|
||||
});
|
||||
|
||||
test("getUsers", async () => {
|
||||
fetch.mockResponse(JSON.stringify({ results: ["foo", "bar"] }));
|
||||
|
||||
const users = await api.getUsers();
|
||||
expect(users).toEqual(["foo", "bar"]);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual("http://localhost/api/0/list");
|
||||
});
|
||||
|
||||
test("getDevices", async () => {
|
||||
fetch.mockResponses(
|
||||
[JSON.stringify({ results: ["phone", "tablet"] })],
|
||||
[JSON.stringify({ results: ["laptop"] })]
|
||||
);
|
||||
|
||||
const devices = await api.getDevices(["foo", "bar"]);
|
||||
expect(devices).toEqual({ foo: ["phone", "tablet"], bar: ["laptop"] });
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(2);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/list?user=foo"
|
||||
);
|
||||
expect(fetch.mock.calls[1][0]).toEqual(
|
||||
"http://localhost/api/0/list?user=bar"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLastLocations", async () => {
|
||||
const response = [
|
||||
{
|
||||
_type: "location",
|
||||
tid: "pp",
|
||||
lat: 51.47879,
|
||||
lon: -0.010677,
|
||||
tst: 0,
|
||||
_http: true,
|
||||
topic: "owntracks/ping/ping",
|
||||
username: "ping",
|
||||
device: "ping",
|
||||
ghash: "gcpuzg2",
|
||||
isotst: "1970-01-01T00:00:00Z",
|
||||
disptst: "1970-01-01 00:00:00",
|
||||
},
|
||||
];
|
||||
fetch.mockResponse(JSON.stringify(response));
|
||||
|
||||
const lastLocation = await api.getLastLocations();
|
||||
expect(lastLocation).toEqual(response);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual("http://localhost/api/0/last");
|
||||
});
|
||||
|
||||
test("getLastLocations with user", async () => {
|
||||
const response = [
|
||||
// Other properties not relevant for testing
|
||||
{
|
||||
username: "foo",
|
||||
device: "phone",
|
||||
},
|
||||
{
|
||||
username: "foo",
|
||||
device: "tablet",
|
||||
},
|
||||
];
|
||||
fetch.mockResponse(JSON.stringify(response));
|
||||
|
||||
const lastLocation = await api.getLastLocations("foo");
|
||||
expect(lastLocation).toEqual(response);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/last?user=foo"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLastLocations with user and device", async () => {
|
||||
const response = [
|
||||
// Other properties not relevant for testing
|
||||
{
|
||||
username: "foo",
|
||||
device: "phone",
|
||||
},
|
||||
];
|
||||
fetch.mockResponse(JSON.stringify(response));
|
||||
|
||||
const lastLocation = await api.getLastLocations("foo", "phone");
|
||||
expect(lastLocation).toEqual(response);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/last?user=foo&device=phone"
|
||||
);
|
||||
});
|
||||
|
||||
test("getUserDeviceLocationHistory", async () => {
|
||||
const response = {
|
||||
count: 1,
|
||||
data: [
|
||||
{
|
||||
batt: 100,
|
||||
lon: -0.010677,
|
||||
acc: 20,
|
||||
bs: 1,
|
||||
vac: 10,
|
||||
topic: "owntracks/foo/phone",
|
||||
lat: 51.47879,
|
||||
conn: "w",
|
||||
tst: 1568841029,
|
||||
alt: 31,
|
||||
_type: "location",
|
||||
tid: "AD",
|
||||
_http: true,
|
||||
ghash: "gcpv4k9",
|
||||
isorcv: "2019-09-18T21:10:29Z",
|
||||
isotst: "2019-09-18T21:10:29Z",
|
||||
disptst: "2019-09-18 21:10:29",
|
||||
},
|
||||
],
|
||||
status: 200,
|
||||
};
|
||||
fetch.mockResponse(JSON.stringify(response));
|
||||
|
||||
const locationHistory = await api.getUserDeviceLocationHistory(
|
||||
"foo",
|
||||
"phone",
|
||||
"1970-01-01T00:00:00",
|
||||
"1970-12-31T23:59:59"
|
||||
);
|
||||
expect(locationHistory).toEqual(response.data);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLocationHistory", async () => {
|
||||
fetch.mockResponses(
|
||||
[
|
||||
JSON.stringify({
|
||||
count: 1,
|
||||
data: [
|
||||
{
|
||||
topic: "owntracks/foo/phone",
|
||||
},
|
||||
],
|
||||
status: 200,
|
||||
}),
|
||||
],
|
||||
[
|
||||
JSON.stringify({
|
||||
count: 1,
|
||||
data: [
|
||||
{
|
||||
topic: "owntracks/foo/tablet",
|
||||
},
|
||||
],
|
||||
status: 200,
|
||||
}),
|
||||
],
|
||||
[
|
||||
JSON.stringify({
|
||||
count: 1,
|
||||
data: [
|
||||
{
|
||||
topic: "owntracks/bar/laptop",
|
||||
},
|
||||
],
|
||||
status: 200,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
const locationHistory = await api.getLocationHistory(
|
||||
{ foo: ["phone", "tablet"], bar: ["laptop"] },
|
||||
"1970-01-01T00:00:00",
|
||||
"1970-12-31T23:59:59"
|
||||
);
|
||||
expect(locationHistory).toEqual({
|
||||
foo: {
|
||||
phone: [{ topic: "owntracks/foo/phone" }],
|
||||
tablet: [{ topic: "owntracks/foo/tablet" }],
|
||||
},
|
||||
bar: { laptop: [{ topic: "owntracks/bar/laptop" }] },
|
||||
});
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(3);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
|
||||
);
|
||||
expect(fetch.mock.calls[1][0]).toEqual(
|
||||
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=tablet&format=json"
|
||||
);
|
||||
expect(fetch.mock.calls[2][0]).toEqual(
|
||||
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=bar&device=laptop&format=json"
|
||||
);
|
||||
});
|
||||
});
|
||||
1
tests/setup.js
Normal file
@@ -0,0 +1 @@
|
||||
require("jest-fetch-mock").enableMocks();
|
||||
124
tests/util.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import config from "@/config";
|
||||
import {
|
||||
getApiUrl,
|
||||
isIsoDateTime,
|
||||
degreesToRadians,
|
||||
distanceBetweenCoordinates,
|
||||
humanReadableDistance,
|
||||
} from "@/util";
|
||||
|
||||
describe("getApiUrl", () => {
|
||||
test("without base URL", () => {
|
||||
// See testURL in jest.config.js
|
||||
expect(getApiUrl("foo").href).toBe("http://localhost/foo");
|
||||
expect(getApiUrl("/foo").href).toBe("http://localhost/foo");
|
||||
expect(getApiUrl("/foo/bar").href).toBe("http://localhost/foo/bar");
|
||||
});
|
||||
|
||||
test("with base URL", () => {
|
||||
config.api.baseUrl = "http://example.com/owntracks";
|
||||
expect(getApiUrl("foo").href).toBe("http://example.com/owntracks/foo");
|
||||
expect(getApiUrl("/foo").href).toBe("http://example.com/owntracks/foo");
|
||||
expect(getApiUrl("/foo/bar").href).toBe(
|
||||
"http://example.com/owntracks/foo/bar"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIsoDateTime", () => {
|
||||
test("no match", () => {
|
||||
expect(isIsoDateTime("foo")).toBe(false);
|
||||
expect(isIsoDateTime("2019")).toBe(false);
|
||||
expect(isIsoDateTime("2019-09")).toBe(false);
|
||||
expect(isIsoDateTime("2019.09.27")).toBe(false);
|
||||
expect(isIsoDateTime("2019_09_27")).toBe(false);
|
||||
expect(isIsoDateTime("2019/09/27")).toBe(false);
|
||||
expect(isIsoDateTime("27-09-2019")).toBe(false);
|
||||
expect(isIsoDateTime("27.09.2019")).toBe(false);
|
||||
expect(isIsoDateTime("27_09_2019")).toBe(false);
|
||||
expect(isIsoDateTime("27/09/2019")).toBe(false);
|
||||
expect(isIsoDateTime("0000-00-00")).toBe(false);
|
||||
expect(isIsoDateTime("1234-56-78")).toBe(false);
|
||||
expect(isIsoDateTime("0000-00-00T00:00:00")).toBe(false);
|
||||
expect(isIsoDateTime("0000-01-01T25:60:60")).toBe(false);
|
||||
expect(isIsoDateTime("2019-12-14T99:00:00")).toBe(false);
|
||||
expect(isIsoDateTime("2019-12-14 25:60:60")).toBe(false);
|
||||
});
|
||||
|
||||
test("match", () => {
|
||||
expect(isIsoDateTime("0000-01-01T00:00:00")).toBe(true);
|
||||
expect(isIsoDateTime("0000-01-01T12:34:56")).toBe(true);
|
||||
expect(isIsoDateTime("0000-01-01T23:59:59")).toBe(true);
|
||||
expect(isIsoDateTime("2019-09-27T00:00:00")).toBe(true);
|
||||
expect(isIsoDateTime("2019-09-27T12:34:56")).toBe(true);
|
||||
expect(isIsoDateTime("2019-09-27T23:59:59")).toBe(true);
|
||||
expect(isIsoDateTime("9999-12-31T00:00:00")).toBe(true);
|
||||
expect(isIsoDateTime("9999-12-31T12:34:56")).toBe(true);
|
||||
expect(isIsoDateTime("9999-12-31T23:59:59")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("degreesToRadians", () => {
|
||||
test("expected results", () => {
|
||||
expect(degreesToRadians(0)).toBe(0);
|
||||
expect(degreesToRadians(45)).toBe(0.7853981633974483);
|
||||
expect(degreesToRadians(90)).toBe(1.5707963267948966);
|
||||
expect(degreesToRadians(180)).toBe(3.141592653589793);
|
||||
expect(degreesToRadians(360)).toBe(6.283185307179586);
|
||||
expect(degreesToRadians(-180)).toBe(-3.141592653589793);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distanceBetweenCoordinates", () => {
|
||||
test("expected results", () => {
|
||||
expect(
|
||||
distanceBetweenCoordinates({ lat: 0, lng: 0 }, { lat: 0, lng: 0 })
|
||||
).toBe(0);
|
||||
|
||||
// The Shard - Victoria Memorial
|
||||
expect(
|
||||
distanceBetweenCoordinates(
|
||||
{ lat: 51.5046678, lng: -0.0870769 },
|
||||
{ lat: 51.501752, lng: -0.1408258 }
|
||||
)
|
||||
// 3.74km according to Google Maps
|
||||
).toBe(3734.3632679046705);
|
||||
|
||||
// Gatwick Airport - Heathrow Airport
|
||||
expect(
|
||||
distanceBetweenCoordinates(
|
||||
{ lat: 51.1526929, lng: -0.1752475 },
|
||||
{ lat: 51.4720694, lng: -0.4499871 }
|
||||
)
|
||||
// 40km according to Google Maps
|
||||
).toBe(40321.457586930104);
|
||||
|
||||
// Berlin - San Francisco
|
||||
expect(
|
||||
distanceBetweenCoordinates(
|
||||
{ lat: 52.5067614, lng: 13.284651 },
|
||||
{ lat: 37.7576948, lng: -122.4726193 }
|
||||
)
|
||||
// 9,102.73km according to Google Maps
|
||||
).toBe(9105627.810109457);
|
||||
});
|
||||
});
|
||||
|
||||
describe("humanReadableDistance", () => {
|
||||
test("expected results", () => {
|
||||
expect(humanReadableDistance(0)).toBe("0 m");
|
||||
expect(humanReadableDistance(1)).toBe("1 m");
|
||||
expect(humanReadableDistance(123)).toBe("123 m");
|
||||
expect(humanReadableDistance(123.4567)).toBe("123.5 m");
|
||||
expect(humanReadableDistance(999)).toBe("999 m");
|
||||
expect(humanReadableDistance(1000)).toBe("1 km");
|
||||
expect(humanReadableDistance(9000)).toBe("9 km");
|
||||
expect(humanReadableDistance(9900)).toBe("9.9 km");
|
||||
expect(humanReadableDistance(9990)).toBe("10 km");
|
||||
expect(humanReadableDistance(9999)).toBe("10 km");
|
||||
expect(humanReadableDistance(9999.0)).toBe("10 km");
|
||||
expect(humanReadableDistance(9999.9999)).toBe("10 km");
|
||||
expect(humanReadableDistance(100000)).toBe("100 km");
|
||||
expect(humanReadableDistance(-42)).toBe("-42 m");
|
||||
});
|
||||
});
|
||||
27
vue.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const fs = require("fs");
|
||||
const webpack = require("webpack");
|
||||
const MomentLocalesPlugin = require("moment-locales-webpack-plugin");
|
||||
|
||||
const packageJson = fs.readFileSync("./package.json");
|
||||
const version = JSON.parse(packageJson).version;
|
||||
|
||||
module.exports = {
|
||||
configureWebpack: {
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
"process.env": {
|
||||
PACKAGE_VERSION: `"${version}"`,
|
||||
},
|
||||
}),
|
||||
new MomentLocalesPlugin(),
|
||||
],
|
||||
},
|
||||
|
||||
pluginOptions: {
|
||||
i18n: {
|
||||
locale: "en",
|
||||
fallbackLocale: "en",
|
||||
localeDir: "locales",
|
||||
},
|
||||
},
|
||||
};
|
||||