Compare commits
284 Commits
v1.1.0
...
jpmens-aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f780bbede3 | ||
|
|
7877aaa9f8 | ||
|
|
341ce4c353 | ||
|
|
5ecad2bf40 | ||
|
|
3365959ed3 | ||
|
|
15a40f9c6c | ||
|
|
9839b5acdd | ||
|
|
0300e2fb4f | ||
|
|
4c680590a4 | ||
|
|
551b226fd0 | ||
|
|
132f15c52b | ||
|
|
c60bfb5368 | ||
|
|
31bf39795c | ||
|
|
d5c87a8727 | ||
|
|
0492b355bf | ||
|
|
998a97131b | ||
|
|
a44965226c | ||
|
|
554ce3f585 | ||
|
|
05ae629217 | ||
|
|
b141444b56 | ||
|
|
723ce684ae | ||
|
|
ed3e6125e9 | ||
|
|
8df1f86ab9 | ||
|
|
b5442363d6 | ||
|
|
35d55b57b1 | ||
|
|
976bb403d1 | ||
|
|
cecf7e797d | ||
|
|
91d99cd8da | ||
|
|
06faa73b70 | ||
|
|
7398da74c5 | ||
|
|
7b954dfbe3 | ||
|
|
c569aced1e | ||
|
|
3fad44509e | ||
|
|
aa13ddd832 | ||
|
|
6a2b113fb2 | ||
|
|
6f047ffa77 | ||
|
|
d5d6c1c268 | ||
|
|
4e86d8fac3 | ||
|
|
1cb6e3519e | ||
|
|
f5389b84ab | ||
|
|
9bb2edb78d | ||
|
|
6361d8f415 | ||
|
|
791b756d80 | ||
|
|
f1ef82d7bb | ||
|
|
aaef181141 | ||
|
|
b2273c071b | ||
|
|
865c89b43c | ||
|
|
32c64d18f5 | ||
|
|
5a64c06af0 | ||
|
|
8c3681b6ad | ||
|
|
89899de565 | ||
|
|
a386c15de1 | ||
|
|
8ac24c99aa | ||
|
|
f3cbf877f9 | ||
|
|
f5c1c82010 | ||
|
|
3ea1d02c65 | ||
|
|
f91341b205 | ||
|
|
0c983d6206 | ||
|
|
9e36d31997 | ||
|
|
5e37c7f4b8 | ||
|
|
7dda60d457 | ||
|
|
228900ff9f | ||
|
|
129446de1a | ||
|
|
af6c308bd6 | ||
|
|
223e19a118 | ||
|
|
1260814309 | ||
|
|
cfffbe9472 | ||
|
|
4031bda2f0 | ||
|
|
69094e240e | ||
|
|
dfa7a423fa | ||
|
|
411bc10b0b | ||
|
|
a994051940 | ||
|
|
d325543bc6 | ||
|
|
80d3060fa8 | ||
|
|
e6c79ac606 | ||
|
|
0b1271502f | ||
|
|
fdddd8e035 | ||
|
|
245c1295e5 | ||
|
|
9786487646 | ||
|
|
b3529c211c | ||
|
|
55178c7cc8 | ||
|
|
2fcf2151fa | ||
|
|
5c6370090f | ||
|
|
fc0189e5e2 | ||
|
|
04fb50667b | ||
|
|
6359b4783c | ||
|
|
4679f7fbb7 | ||
|
|
b29cd12ed9 | ||
|
|
a9026c7a0a | ||
|
|
27070812a4 | ||
|
|
75e79fb0b1 | ||
|
|
4bb9a20787 | ||
|
|
1a47fd1b6c | ||
|
|
163e0e3ec7 | ||
|
|
8d8664a338 | ||
|
|
045e635c21 | ||
|
|
7db7837dfd | ||
|
|
beb522c03e | ||
|
|
658cb6b223 | ||
|
|
7ab98be4ad | ||
|
|
6d4d47b5a1 | ||
|
|
159470181c | ||
|
|
b53a0be707 | ||
|
|
4e70d3a3ad | ||
|
|
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*
|
||||
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
liberapay: owntracks.org
|
||||
14
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Build
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
79
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Build Docker images
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *' # everyday at 3am
|
||||
pull_request:
|
||||
branches: main
|
||||
push:
|
||||
branches: main
|
||||
tags:
|
||||
- v*
|
||||
release:
|
||||
types: [published]
|
||||
branches: main
|
||||
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=main
|
||||
|
||||
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 .
|
||||
# https://github.com/docker/setup-qemu-action
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- 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
|
||||
16
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Lint
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install
|
||||
- run: npm run lint:js
|
||||
- run: npm run lint:md
|
||||
- run: npm run lint:scss
|
||||
14
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Tests
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
20
.github/workflows/upload-dist.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Upload dist/
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
upload-dist:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
- run: zip -r $GITHUB_REF_NAME-dist.zip dist/
|
||||
- run: gh release upload $GITHUB_REF_NAME $GITHUB_REF_NAME-dist.zip
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
|
||||
259
CHANGELOG.md
@@ -1,9 +1,258 @@
|
||||
# 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.15.3 (2024-06-15)
|
||||
|
||||
- Force relative path for `config/config.js` even if it doesn't exist at build time
|
||||
|
||||
## 2.15.2 (2024-06-14)
|
||||
|
||||
- Fix npm lockfile
|
||||
|
||||
## 2.15.1 (2024-06-14)
|
||||
|
||||
- Update `index.html` to emit relative paths again, allowing deployment under a subpath
|
||||
- Update Docker image to use nginx 1.27
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.15.0 (2024-06-10)
|
||||
|
||||
- Implement POI map layer (see [Booklet](https://owntracks.org/booklet/features/poi/))
|
||||
|
||||
- Use the `map.poiMarker` config option to tweak the appearance, defaults to a red circle slightly larger than the default location points
|
||||
- Use `map.layers.poi` to change the layer visibility, defaults to `true`
|
||||
|
||||
## 2.14.0 (2024-06-09)
|
||||
|
||||
- Implement new date/time range picker ([#116](https://github.com/owntracks/frontend/pull/116), [@jduar](https://github.com/jduar) / [@Tofee](https://github.com/Tofee))
|
||||
|
||||
## 2.13.1 (2024-06-09)
|
||||
|
||||
- Bump versions, just to make sure the frontend shows the right one
|
||||
|
||||
## 2.13.0 (2024-06-09)
|
||||
|
||||
- Enable use of the frontend as a progressive web app (PWA) ([#98](https://github.com/owntracks/frontend/pull/98), [@RobinMeis](https://github.com/RobinMeis))
|
||||
- Add Turkish translations ([#94](https://github.com/owntracks/frontend/pull/94), [@ramazansancar](https://github.com/ramazansancar))
|
||||
- Add Slovak translations ([#110](https://github.com/owntracks/frontend/pull/110), [@aasami](https://github.com/aasami))
|
||||
- Add Czech translations ([#115](https://github.com/owntracks/frontend/pull/115), [@jmencak](https://github.com/jmencak))
|
||||
- Add action for uploading dist/ on release ([#114](https://github.com/owntracks/frontend/pull/114), [@abaumg](https://github.com/abaumg))
|
||||
- Replace outdated Twitter link with Mastodon
|
||||
- Remove the download modal
|
||||
- Show isolocal and tzname properties on the popup
|
||||
- Various changes to the underlying frontend build system:
|
||||
- Bump node to version 20
|
||||
- Switch from yarn to npm
|
||||
- Migrate from vue-cli / webpack to vite
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.12.0 (2022-09-06)
|
||||
|
||||
- Add Danish translation ([#87](https://github.com/owntracks/frontend/pull/87), [@atjn](https://github.com/atjn))
|
||||
- Ensure correct display of larger (192x192) face images ([#83](https://github.com/owntracks/frontend/pull/83), [@atjn](https://github.com/atjn))
|
||||
- Add `map.tileSize` and `map.zoomOffset` options ([#75](https://github.com/owntracks/frontend/pull/75), [@saesh](https://github.com/saesh))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.11.0 (2022-03-16)
|
||||
|
||||
- Show WiFi SSID and BSSID in location popup, if available
|
||||
- Show address in location popup, if available ([#73](https://github.com/owntracks/frontend/pull/73), [@saesh](https://github.com/saesh))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.10.0 (2021-11-28)
|
||||
|
||||
- Ensure location history line segments are drawn in chronological order ([#67](https://github.com/owntracks/frontend/issues/67))
|
||||
- Add trailing slashes to paths used by Docker nginx config ([#63](https://github.com/owntracks/frontend/pull/63), [@growse](https://github.com/growse))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.9.0 (2021-05-01)
|
||||
|
||||
- Add a cancel button to the loading data modal
|
||||
- Replace remaining uses of "OwnTracks UI" with "OwnTracks Frontend"
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.8.0 (2021-02-19)
|
||||
|
||||
- Add elevation gain / loss to "distance travelled" calculation ([#51](https://github.com/owntracks/frontend/issues/51))
|
||||
|
||||
## 2.7.0 (2021-02-14)
|
||||
|
||||
- Rename translation files from `xx` to `xx-XX` format to allow different language variants
|
||||
- Separate `en` translations into British English (`en-GB`) and American English (`en-US`, default)
|
||||
- Add French translations ([#49](https://github.com/owntracks/frontend/pull/49), [@Elu43](https://github.com/Elu43))
|
||||
- Update Docker image to use Node 14 and nginx 1.18
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.6.0 (2020-12-29)
|
||||
|
||||
- Add `router.basePath` config option for non-webroot deployments
|
||||
- Configure Vue to not assume it's on the web root ([#47](https://github.com/owntracks/frontend/pull/47), [@growse](https://github.com/growse))
|
||||
- Update Docker NGINX config to listen on IPv6 as well ([#46](https://github.com/owntracks/frontend/pull/46), [@growse](https://github.com/growse))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.5.1 (2020-10-27)
|
||||
|
||||
- Fix incorrect handling of `api.baseUrl` with trailing slash ([#44](https://github.com/owntracks/frontend/pull/44), [@karmanyaahm](https://github.com/karmanyaahm))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 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-2024 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
|
||||
|
||||
166
README.md
@@ -1,95 +1,159 @@
|
||||
# OwnTracks UI
|
||||
|
||||
> A modern web interface for OwnTracks made with Vue.js
|
||||
# OwnTracks Frontend
|
||||
|
||||

|
||||
[](https://hub.docker.com/r/owntracks/frontend)
|
||||
[](https://github.com/owntracks/frontend/actions?query=workflow%3ABuild+branch%3Amain)
|
||||
[](https://github.com/owntracks/frontend/actions?query=workflow%3ATests+branch%3Amain)
|
||||
[](https://github.com/owntracks/frontend/actions?query=workflow%3ALint+branch%3Amain)
|
||||
[](https://github.com/prettier/prettier)
|
||||
[](https://github.com/owntracks/frontend/blob/main/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
|
||||
- Highly customisable
|
||||
|
||||
## Notes
|
||||
|
||||
Frontend has no provision for user management, authentication, or for configuring TLS; these are tasks which a HTTP server you use will have to provide, and an explanation on how to do that is beyond our scope; you should be able to find a myriad explanatory documents for the server you wish to use. Also note, that even if you set up authentication, any user successfully being able to access Frontend will be able to see all Recorder data, as the API of the latter doesn't distinguish querying users.
|
||||
|
||||
## 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:
|
||||
owntracks-frontend:
|
||||
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 `npm install` to install dependencies
|
||||
- Run `npm run 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 `npm install` to install dependencies
|
||||
- Run `npm run dev` to compile for development and start the hot-reload server
|
||||
- Run `npm run lint:js` to lint JavaScript/Vue files
|
||||
- Run `npm run lint:md` to lint Markdown files
|
||||
- Run `npm run lint:scss` to lint SCSS files
|
||||
- Run `npm run format:js` to format JavaScript/Vue files
|
||||
- Run `npm run format:md` to format Markdown files
|
||||
- Run `npm run format:scss` to format SCSS files
|
||||
- Run `npm 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
|
||||
$ npm run 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) npm run 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
|
||||
$ npm run i18n:report
|
||||
```
|
||||
|
||||
To add a new locale, copy `en-US.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!
|
||||
|
||||
24
docker/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:20 as build
|
||||
WORKDIR /usr/src/app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
LABEL version="2.15.3"
|
||||
LABEL description="OwnTracks Frontend"
|
||||
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 ) \
|
||||
"
|
||||
@@ -8,11 +8,12 @@ http {
|
||||
}
|
||||
server {
|
||||
listen ${LISTEN_PORT};
|
||||
listen [::]:${LISTEN_PORT};
|
||||
root /usr/share/nginx/html;
|
||||
location /api {
|
||||
location /api/ {
|
||||
proxy_pass http://otrecorder/api/;
|
||||
}
|
||||
location /ws {
|
||||
location /ws/ {
|
||||
proxy_pass http://otrecorder/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -30,6 +31,7 @@ http {
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_types text/plain text/css application/json application/javascript text/javascript;
|
||||
proxy_read_timeout 600;
|
||||
}
|
||||
}
|
||||
|
||||
581
docs/config.md
Normal file
@@ -0,0 +1,581 @@
|
||||
# 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)
|
||||
- [`poi`](#maplayerspoi)
|
||||
- [`points`](#maplayerspoints)
|
||||
- [`maxNativeZoom`](#mapmaxnativezoom)
|
||||
- [`maxPointDistance`](#mapmaxpointdistance)
|
||||
- [`maxZoom`](#mapmaxzoom)
|
||||
- [`poiMarker`](#mappoimarker)
|
||||
- [`polyline`](#mappolyline)
|
||||
- [`url`](#mapurl)
|
||||
- `onLocationChange`
|
||||
- [`fitView`](#onlocationchangefitview)
|
||||
- [`reloadHistory`](#onlocationchangereloadhistory)
|
||||
- [`primaryColor`](#primarycolor)
|
||||
- `router`
|
||||
- [`basePath`](#routerbasepath)
|
||||
- [`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:
|
||||
|
||||
- `cs-CZ` (Standard Czech)
|
||||
- `da-DK` (Standard Danish)
|
||||
- `de-DE` (Standard German)
|
||||
- `en-GB` (British English)
|
||||
- `en-US` (American English)
|
||||
- `es-ES` (Castilian Spanish)
|
||||
- `fr-FR` (Standard French)
|
||||
- `sk-SK` (Standard Slovak)
|
||||
- `tr-TR` (Standard Turkish)
|
||||
|
||||
Using a locale with non-existent translations is possible and will affect date/time formats, but
|
||||
use `en-US` for translations.
|
||||
|
||||
- Type: [`String`]
|
||||
- Default: `"en-US"`
|
||||
|
||||
### `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.poi`
|
||||
|
||||
Initial visibility of the POI layer.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `map.layers.points`
|
||||
|
||||
Initial visibility of the location 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.poiMarker`
|
||||
|
||||
POI marker configuration. See [Vue2Leaflet `l-circle-marker` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-circle-marker/)
|
||||
for all possible values.
|
||||
|
||||
- Type: [`Object`]
|
||||
- Default:
|
||||
```js
|
||||
{
|
||||
color: "red",
|
||||
fillColor: "red",
|
||||
fillOpacity: 0.2,
|
||||
radius: 12
|
||||
}
|
||||
```
|
||||
|
||||
### `map.polyline`
|
||||
|
||||
Location point marker configuration. `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.tileSize`
|
||||
|
||||
Size of the tiles in pixels returned by the tile server. Can be used together with
|
||||
[`map.zoomOffset`](#map.zoomOffset) to configure bigger tile sizes.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `256`
|
||||
|
||||
### `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/styles/v1/mapbox/dark-v10/tiles/{z}/{x}/{y}@2x?access_token=xxxxxxxxxxxxxxxx",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `map.zoomOffset`
|
||||
|
||||
Offset the zoom level to account for different tile sizes. For example tiles with a
|
||||
size of 512x512 need an offset of -1 and for 1024x1024 an offset of -2.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `0`
|
||||
|
||||
### `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",
|
||||
};
|
||||
```
|
||||
|
||||
### `router.basePath`
|
||||
|
||||
Base path of the application deployment.
|
||||
|
||||
- Type: [`String`]
|
||||
- Default: `"/"`
|
||||
- Example:
|
||||
```js
|
||||
// Frontend will be reachable at https://example.com/owntracks
|
||||
window.owntracks.config = {
|
||||
router: {
|
||||
basePath: "/owntracks",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `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.
|
||||
|
||||
This also includes a calculation of elevation gain / loss.
|
||||
|
||||
- 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 |
BIN
docs/images/screenshot.png
Normal file
|
After Width: | Height: | Size: 653 KiB |
47
eslint.config.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import eslintPluginVue from "eslint-plugin-vue";
|
||||
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
|
||||
import vueParser from "vue-eslint-parser";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const eslintrc = new FlatCompat({
|
||||
baseDirectory: dirname(fileURLToPath(import.meta.url)),
|
||||
});
|
||||
|
||||
export default [
|
||||
...eslintrc.extends("plugin:vue/essential"),
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
parser: vueParser,
|
||||
},
|
||||
plugins: {
|
||||
vue: eslintPluginVue,
|
||||
},
|
||||
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",
|
||||
},
|
||||
],
|
||||
"vue/multi-word-component-names": [
|
||||
"error",
|
||||
{
|
||||
ignores: ["Map"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
228
index.html
@@ -1,224 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json">
|
||||
<title>OwnTracks Frontend</title>
|
||||
</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>
|
||||
<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="./config/config.js"></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
4911
package-lock.json
generated
Normal file
62
package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "owntracks-frontend",
|
||||
"version": "2.15.3",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Linus Groh",
|
||||
"email": "mail@linusgroh.de"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/owntracks/frontend.git"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"cors-proxy": "node scripts/corsProxy.js",
|
||||
"format:js": "eslint --fix 'src/**/*.{js,vue}'",
|
||||
"format:md": "prettier --write '{*.md,docs/**/*.md,src/**/*.md}'",
|
||||
"format:scss": "prettier --write 'src/**/*.scss'",
|
||||
"lint:js": "eslint 'src/**/*.{js,vue}'",
|
||||
"lint:md": "prettier --check '{*.md,docs/**/*.md,src/**/*.md}'",
|
||||
"lint:scss": "prettier --check 'src/**/*.scss'",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"moment": "^2.30.1",
|
||||
"vue": "^2.7.16",
|
||||
"vue-feather-icons": "^5.1.0",
|
||||
"vue-i18n": "^8.28.2",
|
||||
"vue-js-modal": "^2.0.1",
|
||||
"vue-mq": "^1.0.1",
|
||||
"vue-outside-events": "^1.1.3",
|
||||
"vue-router": "^3.6.5",
|
||||
"vue2-datepicker": "^3.11.1",
|
||||
"vue2-leaflet": "^2.7.1",
|
||||
"vuex": "^3.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@vitejs/plugin-vue2": "^2.3.1",
|
||||
"cors-anywhere": "^0.4.4",
|
||||
"eslint": "^9.6.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"jsdom": "^24.1.0",
|
||||
"lint-staged": "^15.2.7",
|
||||
"moment-locales-webpack-plugin": "^1.2.0",
|
||||
"prettier": "^3.3.2",
|
||||
"sass": "^1.77.6",
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-package-version": "^1.1.0",
|
||||
"vitest": "^1.6.0",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vue-eslint-parser": "^9.4.3"
|
||||
}
|
||||
}
|
||||
189
public/OwnTracks.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
3
public/config/config.example.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Here you can overwrite the default configuration values
|
||||
window.owntracks = window.owntracks || {};
|
||||
window.owntracks.config = {};
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/icon-180x180.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
20
public/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "OwnTracks Frontend",
|
||||
"description": "OwnTracks Frontend",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-180x180.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180"
|
||||
},
|
||||
{
|
||||
"src": "OwnTracks.svg",
|
||||
"sizes": "any"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"background_color": "#3f51b5",
|
||||
"display": "standalone",
|
||||
"scope": ".",
|
||||
"theme_color": "#3f51b5"
|
||||
}
|
||||
25
scripts/corsProxy.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createServer } from "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"
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
createServer(options).listen(port, host, () => {
|
||||
console.log(`Running CORS Anywhere on http://${host}:${port}`);
|
||||
});
|
||||
95
src/App.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<AppHeader />
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
<InformationModal />
|
||||
<LoadingModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from "vuex";
|
||||
|
||||
import * as types from "@/store/mutation-types";
|
||||
import { log } from "@/logging";
|
||||
import AppHeader from "@/components/AppHeader.vue";
|
||||
import InformationModal from "@/components/modals/InformationModal.vue";
|
||||
import LoadingModal from "@/components/modals/LoadingModal.vue";
|
||||
|
||||
export default {
|
||||
components: { AppHeader, 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>
|
||||
|
||||
<style lang="scss">
|
||||
@import "styles/main";
|
||||
</style>
|
||||
235
src/api.js
Normal file
@@ -0,0 +1,235 @@
|
||||
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
|
||||
* @param {Object} [fetchOptions]
|
||||
* fetch() options (merged with config.api.fetchOptions)
|
||||
* @returns {Promise<Response>} Response returned by the fetch call
|
||||
*/
|
||||
const fetchApi = (path, params = {}, fetchOptions = {}) => {
|
||||
const url = getApiUrl(path);
|
||||
Object.keys(params).forEach((key) => url.searchParams.set(key, params[key]));
|
||||
log("HTTP", `GET ${url.href}`);
|
||||
return fetch(url.href, {
|
||||
...fetchOptions,
|
||||
...config.api.fetchOptions,
|
||||
}).catch((error) => {
|
||||
if (error.name === "AbortError") {
|
||||
log("HTTP", `GET ${url.href} - Request was aborted`, logLevels.WARNING);
|
||||
} else {
|
||||
log("HTTP", error, logLevels.ERROR);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the recorder's version.
|
||||
*
|
||||
* @returns {Promise<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 {Promise<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 {Promise<{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 {Promise<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
|
||||
* @param {Object} [fetchOptions] fetch() options
|
||||
* @returns {Promise<OTLocation[]>} Array of location history objects
|
||||
*/
|
||||
export const getUserDeviceLocationHistory = async (
|
||||
user,
|
||||
device,
|
||||
start,
|
||||
end,
|
||||
fetchOptions
|
||||
) => {
|
||||
const response = await fetchApi(
|
||||
"/api/0/locations",
|
||||
{
|
||||
from: start,
|
||||
to: end,
|
||||
user,
|
||||
device,
|
||||
format: "json",
|
||||
},
|
||||
fetchOptions
|
||||
);
|
||||
const json = await response.json();
|
||||
// We need to manually sort by timestamp, otherwise the line segments may be
|
||||
// drawn in the wrong order. The recorder API simply returns entries in the
|
||||
// same order in which they are in each *.rec file.
|
||||
// See https://github.com/owntracks/frontend/issues/67.
|
||||
const userDeviceLocationHistory = json.data.sort((a, b) => a.tst - b.tst);
|
||||
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
|
||||
* @param {Object} [fetchOptions] fetch() options
|
||||
* @returns {Promise<LocationHistory>} Location history
|
||||
*/
|
||||
export const getLocationHistory = async (devices, start, end, fetchOptions) => {
|
||||
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,
|
||||
fetchOptions
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
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");
|
||||
}
|
||||
};
|
||||
};
|
||||
378
src/components/AppHeader.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<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" />
|
||||
<DropdownButton
|
||||
: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>
|
||||
</DropdownButton>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<CalendarIcon size="1x" aria-hidden="true" role="img" />
|
||||
<date-picker
|
||||
v-model="dateTimeRange"
|
||||
type="datetime"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:editable="false"
|
||||
:clearable="false"
|
||||
:confirm="true"
|
||||
:show-second="false"
|
||||
:range="true"
|
||||
range-separator=" – "
|
||||
:shortcuts="shortcuts"
|
||||
:show-time-panel="showTimeRangePanel"
|
||||
:disabled-date="(date, _) => date > new Date()"
|
||||
@change="handleDateTimeRangeChange"
|
||||
>
|
||||
<template v-slot:footer>
|
||||
<button
|
||||
class="mx-btn toggle-date-btn"
|
||||
type="button"
|
||||
@click="toggleTimeRangePanel"
|
||||
>
|
||||
{{ showTimeRangePanel ? $t("Select date") : $t("Select time") }}
|
||||
</button>
|
||||
</template>
|
||||
</date-picker>
|
||||
</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" :key="user" :value="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]"
|
||||
:key="`${selectedUser}-${device}`"
|
||||
:value="device"
|
||||
>
|
||||
{{ device }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</nav>
|
||||
<nav class="header-item header-item-right">
|
||||
<div
|
||||
v-if="$config.showDistanceTravelled && distanceTravelled"
|
||||
class="nav-item distance-travelled"
|
||||
>
|
||||
<span :title="$t('Distance travelled')">
|
||||
{{ humanReadableDistance(distanceTravelled) }}
|
||||
</span>
|
||||
<br />
|
||||
<span :title="$t('Elevation gain / loss')">
|
||||
<ArrowUpIcon size="0.8x" role="img" />
|
||||
{{ humanReadableDistance(elevationGain) }}
|
||||
/
|
||||
<ArrowDownIcon size="0.8x" role="img" />
|
||||
{{ humanReadableDistance(elevationLoss) }}
|
||||
</span>
|
||||
</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 {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CalendarIcon,
|
||||
CrosshairIcon,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
MenuIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
} from "vue-feather-icons";
|
||||
|
||||
import DatePicker from "vue2-datepicker";
|
||||
import "vue2-datepicker/index.css";
|
||||
|
||||
import DropdownButton from "@/components/DropdownButton.vue";
|
||||
import { DATE_TIME_FORMAT } from "@/constants";
|
||||
import * as types from "@/store/mutation-types";
|
||||
import { humanReadableDistance } from "@/util";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CalendarIcon,
|
||||
CrosshairIcon,
|
||||
DatePicker,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
MenuIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
DropdownButton,
|
||||
},
|
||||
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") },
|
||||
{ layer: "poi", label: this.$t("Show points of interest") },
|
||||
],
|
||||
showMobileNav: false,
|
||||
shortcuts: [
|
||||
{
|
||||
text: this.$t("Today"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("Yesterday"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() - 1);
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date(end);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("3 days"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 3);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("7 days"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 7);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("15 days"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 15);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("30 days"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 30);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("3 months"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setMonth(end.getMonth() - 3);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("6 months"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setMonth(end.getMonth() - 6);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("1 year"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setFullYear(end.getFullYear() - 1);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
],
|
||||
showTimeRangePanel: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState([
|
||||
"users",
|
||||
"devices",
|
||||
"map",
|
||||
"distanceTravelled",
|
||||
"elevationGain",
|
||||
"elevationLoss",
|
||||
]),
|
||||
selectedUser: {
|
||||
get() {
|
||||
return this.$store.state.selectedUser;
|
||||
},
|
||||
set(value) {
|
||||
this.setSelectedUser(value);
|
||||
},
|
||||
},
|
||||
selectedDevice: {
|
||||
get() {
|
||||
return this.$store.state.selectedDevice;
|
||||
},
|
||||
set(value) {
|
||||
this.setSelectedDevice(value);
|
||||
},
|
||||
},
|
||||
dateTimeRange: {
|
||||
get() {
|
||||
const startDateTime = moment
|
||||
.utc(this.$store.state.startDateTime, DATE_TIME_FORMAT)
|
||||
.local()
|
||||
.toDate();
|
||||
const endDateTime = moment
|
||||
.utc(this.$store.state.endDateTime, DATE_TIME_FORMAT)
|
||||
.local()
|
||||
.toDate();
|
||||
return [startDateTime, endDateTime];
|
||||
},
|
||||
set([startDateTime, endDateTime]) {
|
||||
this.setStartDateTime(
|
||||
moment(startDateTime).utc().format(DATE_TIME_FORMAT)
|
||||
);
|
||||
|
||||
this.setEndDateTime(
|
||||
moment(endDateTime).set("seconds", 59).utc().format(DATE_TIME_FORMAT)
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setMapLayerVisibility: types.SET_MAP_LAYER_VISIBILITY,
|
||||
}),
|
||||
...mapActions([
|
||||
"setSelectedUser",
|
||||
"setSelectedDevice",
|
||||
"setStartDateTime",
|
||||
"setEndDateTime",
|
||||
]),
|
||||
humanReadableDistance,
|
||||
toggleTimeRangePanel() {
|
||||
this.showTimeRangePanel = !this.showTimeRangePanel;
|
||||
},
|
||||
// Resetting to date choice after value change
|
||||
handleDateTimeRangeChange(value, type) {
|
||||
this.showTimeRangePanel = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.distance-travelled {
|
||||
text-align: right;
|
||||
line-height: 1.2;
|
||||
|
||||
.feather {
|
||||
margin-top: 3px;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
src/components/DropdownButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div v-focus-outside="hide" v-click-outside="hide" class="dropdown">
|
||||
<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>`,
|
||||
});
|
||||
190
src/components/LDeviceLocationPopup.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<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 })"
|
||||
class="face"
|
||||
/>
|
||||
<ul class="info-list">
|
||||
<li :title="$t('Timestamp')">
|
||||
<ClockIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ new Date(timestamp * 1000).toLocaleString($config.locale) }}
|
||||
<span v-if="isoLocal && timeZone">
|
||||
<br />
|
||||
<code style="font-size: 0.7rem">
|
||||
{{ isoLocal }}[{{ timeZone }}]
|
||||
</code>
|
||||
</span>
|
||||
</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>
|
||||
<li v-if="wifi.ssid" :title="$t('WiFi')">
|
||||
<WifiIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ wifi.ssid }}
|
||||
<span v-if="wifi.bssid">({{ wifi.bssid }})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="regions.length" class="regions">
|
||||
{{ $t("Regions:") }}
|
||||
{{ regions.join(", ") }}
|
||||
</div>
|
||||
</LPopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
BatteryIcon,
|
||||
ClockIcon,
|
||||
HomeIcon,
|
||||
MapPinIcon,
|
||||
WifiIcon,
|
||||
ZapIcon,
|
||||
} from "vue-feather-icons";
|
||||
import { LPopup } from "vue2-leaflet";
|
||||
|
||||
export default {
|
||||
name: "LDeviceLocationPopup",
|
||||
components: {
|
||||
BatteryIcon,
|
||||
ClockIcon,
|
||||
HomeIcon,
|
||||
MapPinIcon,
|
||||
WifiIcon,
|
||||
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,
|
||||
},
|
||||
isoLocal: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
timeZone: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
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: () => [],
|
||||
},
|
||||
wifi: {
|
||||
type: Object,
|
||||
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>
|
||||
|
||||
<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;
|
||||
|
||||
img {
|
||||
align-self: start;
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
.regions {
|
||||
border-top: 1px solid var(--color-separator);
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
</style>
|
||||
138
src/components/LHeatmap.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
46
src/components/modals/InformationModal.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<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 || $t("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>
|
||||
<AtSignIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://fosstodon.org/@owntracks">
|
||||
{{ $t("OwnTracks on Mastodon") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import { AtSignIcon, BookIcon, GithubIcon, GlobeIcon } from "vue-feather-icons";
|
||||
|
||||
export default {
|
||||
components: { AtSignIcon, BookIcon, GithubIcon, GlobeIcon },
|
||||
computed: {
|
||||
...mapState(["frontendVersion", "recorderVersion"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
49
src/components/modals/LoadingModal.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<modal name="loading" :click-to-close="false" adaptive>
|
||||
<LoaderIcon class="loader-icon" size="1.5x" />
|
||||
{{ $t("Loading data, please wait...") }}
|
||||
<br />
|
||||
<button
|
||||
type="button"
|
||||
class="button button-primary button-cancel"
|
||||
@click="requestAbortController.abort()"
|
||||
>
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import { LoaderIcon } from "vue-feather-icons";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LoaderIcon,
|
||||
},
|
||||
computed: {
|
||||
...mapState(["requestAbortController"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loader-icon {
|
||||
animation: spinning 2s linear infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.button-cancel {
|
||||
display: block;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
@keyframes spinning {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
98
src/config.js
Normal file
@@ -0,0 +1,98 @@
|
||||
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-US",
|
||||
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,
|
||||
poi: true,
|
||||
points: false,
|
||||
},
|
||||
maxNativeZoom: 19,
|
||||
maxPointDistance: null,
|
||||
maxZoom: 21,
|
||||
poiMarker: {
|
||||
color: "red",
|
||||
fillColor: "red",
|
||||
fillOpacity: 0.2,
|
||||
radius: 12,
|
||||
},
|
||||
polyline: {
|
||||
color: null,
|
||||
fillColor: "transparent",
|
||||
},
|
||||
tileSize: 256,
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
zoomOffset: 0,
|
||||
},
|
||||
onLocationChange: {
|
||||
fitView: false,
|
||||
reloadHistory: false,
|
||||
},
|
||||
primaryColor: "#3f51b5",
|
||||
router: {
|
||||
basePath: "/",
|
||||
},
|
||||
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;
|
||||
37
src/i18n.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import Vue from "vue";
|
||||
import VueI18n from "vue-i18n";
|
||||
|
||||
import config from "@/config";
|
||||
|
||||
// TODO: This should be possible to do with https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n,
|
||||
// but that breaks at runtime - may only work with vue-i18n@9?
|
||||
import cs_CZ from "@/locales/cs-CZ.json";
|
||||
import da_DK from "@/locales/da-DK.json";
|
||||
import de_DE from "@/locales/de-DE.json";
|
||||
import en_GB from "@/locales/en-GB.json";
|
||||
import en_US from "@/locales/en-US.json";
|
||||
import es_ES from "@/locales/es-ES.json";
|
||||
import fr_FR from "@/locales/fr-FR.json";
|
||||
import sk_SK from "@/locales/sk-SK.json";
|
||||
import tr_TR from "@/locales/tr-TR.json";
|
||||
|
||||
Vue.use(VueI18n);
|
||||
|
||||
const messages = {
|
||||
"cs-CZ": cs_CZ,
|
||||
"da-DK": da_DK,
|
||||
"de-DE": de_DE,
|
||||
"en-GB": en_GB,
|
||||
"en-US": en_US,
|
||||
"es-ES": es_ES,
|
||||
"fr-FR": fr_FR,
|
||||
"sk-SK": sk_SK,
|
||||
"tr-TR": tr_TR,
|
||||
};
|
||||
|
||||
export default new VueI18n({
|
||||
locale: config.locale,
|
||||
fallbackLocale: "en-US",
|
||||
formatFallbackMessages: true,
|
||||
messages,
|
||||
});
|
||||
256
src/index.d.ts
vendored
Normal file
@@ -0,0 +1,256 @@
|
||||
/** Configuration object. */
|
||||
interface Config {
|
||||
api: {
|
||||
baseUrl: string;
|
||||
fetchOptions: object;
|
||||
};
|
||||
endDateTime: Date;
|
||||
filters: {
|
||||
minAccuracy: number | null,
|
||||
};
|
||||
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;
|
||||
poi: boolean;
|
||||
points: boolean;
|
||||
};
|
||||
maxNativeZoom: number;
|
||||
maxPointDistance: number | null;
|
||||
maxZoom: number;
|
||||
poiMarker: {
|
||||
color: OptionalColor;
|
||||
fillColor: OptionalColor;
|
||||
fillOpacity: number;
|
||||
radius: number;
|
||||
};
|
||||
polyline: {
|
||||
color: OptionalColor;
|
||||
fillColor: OptionalColor;
|
||||
};
|
||||
tileSize: number;
|
||||
url: string;
|
||||
zoomOffset: number;
|
||||
};
|
||||
onLocationChange: {
|
||||
fitView: boolean;
|
||||
reloadHistory: boolean;
|
||||
};
|
||||
primaryColor: Color;
|
||||
router: {
|
||||
basePath: string;
|
||||
};
|
||||
selectedUser: User | null;
|
||||
selectedDevice: Device | null;
|
||||
showDistanceTravelled: boolean;
|
||||
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;
|
||||
poi: 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;
|
||||
/** identifies the time at which the message is constructed (vs. `tst` which is the timestamp of the GPS fix) */
|
||||
created_at?: 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;
|
||||
/** contains a list of regions the device is currently in (e.g. ["Home","Garage"]). Might be empty. */
|
||||
inregions?: string[];
|
||||
/** contains a list of region IDs the device is currently in (e.g. ["6da9cf","3defa7"]). Might be empty. */
|
||||
inrids?: 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;
|
||||
/** ISO 8601 timestamp in local time */
|
||||
isolocal?: string;
|
||||
/** tzdb time zone name */
|
||||
tzname?: string;
|
||||
/** Latitude in degrees */
|
||||
lat: number;
|
||||
/** Longitude in degrees */
|
||||
lon: number;
|
||||
/** Friendly device name */
|
||||
name?: string;
|
||||
/** Point of interest name */
|
||||
poi?: 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;
|
||||
/** SSID, if available, is the unique name of the WLAN. */
|
||||
SSID?: string;
|
||||
/** BSSID, if available, identifies the access point. */
|
||||
BSSID?: string;
|
||||
}
|
||||
|
||||
/** 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[] } };
|
||||
45
src/locales/cs-CZ.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Automaticky vystředit pohled na mapu a přiblížit na příslušné údaje",
|
||||
"Fit view": "Napasovat pohled",
|
||||
"Layer settings": "Nastavení vrstvy",
|
||||
"Show/hide layers": "Zobrazit/skrýť vrstvy",
|
||||
"Now": "Teď",
|
||||
"Select start date": "Zvolit počáteční datum",
|
||||
"to": "do",
|
||||
"Select end date": "Zvolit konečný datum",
|
||||
"Select user": "Zvolit uživatele",
|
||||
"Show all": "Zobrazit všechno",
|
||||
"Select device": "Zvolit zařízení",
|
||||
"Distance travelled": "Procestovaná vzdálenost",
|
||||
"Elevation gain / loss": "Výškový výstup / pokles",
|
||||
"Information": "Informace",
|
||||
"Show last known locations": "Zobrazit naposledy známé polohy",
|
||||
"Show location history (line)": "Zobrazit historii poloh (čára)",
|
||||
"Show location history (points)": "Zobrazit historii poloh (body)",
|
||||
"Show location heatmap": "Zobrazit tepelnou mapu poloh",
|
||||
"Minify JSON": "Zminimalizovat JSON",
|
||||
"Copy to clipboard": "Zkopírovat do schránky",
|
||||
"Loading version...": "Nahrávám verzi...",
|
||||
"OwnTracks website": "Web Stránka OwnTracks",
|
||||
"OwnTracks documentation": "Dokumentace OwnTracks",
|
||||
"OwnTracks on Mastodon": "OwnTracks na Mastodon",
|
||||
"Loading data, please wait...": "Nahrávám údaje, prosím počkejte...",
|
||||
"Cancel": "Zrušit",
|
||||
"Image of {deviceName}": "Obrázek {deviceName}",
|
||||
"Timestamp": "Čas",
|
||||
"Location": "Poloha",
|
||||
"Address": "Adresa",
|
||||
"Battery": "Baterie",
|
||||
"Speed": "Rychlost",
|
||||
"Regions:": "Oblasti:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Dnes",
|
||||
"Yesterday": "Včera",
|
||||
"3 days": "3 dny",
|
||||
"7 days": "7 dní",
|
||||
"15 days": "15 dní",
|
||||
"30 days": "30 dní",
|
||||
"3 months": "3 měsíce",
|
||||
"6 months": "6 měsíců",
|
||||
"1 year": "1 rok"
|
||||
}
|
||||
45
src/locales/da-DK.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Centrér automatisk kortvisningen og zoom ind på relevant data",
|
||||
"Fit view": "Tilpas visning",
|
||||
"Layer settings": "Lag-indstillinger",
|
||||
"Show/hide layers": "Vis/skjul lag",
|
||||
"Now": "Nu",
|
||||
"Select start date": "Vælg startdato",
|
||||
"to": "til",
|
||||
"Select end date": "Vælg slutdato",
|
||||
"Select user": "Vælg bruger",
|
||||
"Show all": "Vis alt",
|
||||
"Select device": "Vælg enhed",
|
||||
"Distance travelled": "Afstand rejst",
|
||||
"Elevation gain / loss": "Højde vundet / tabt",
|
||||
"Information": "Information",
|
||||
"Show last known locations": "Vis sidst kendte positioner",
|
||||
"Show location history (line)": "Vis positionshistorik (linje)",
|
||||
"Show location history (points)": "Vis positionshistorik (punkter)",
|
||||
"Show location heatmap": "Vis positions-heatmap",
|
||||
"Minify JSON": "Minificér JSON",
|
||||
"Copy to clipboard": "Kopiér til udklipsholder",
|
||||
"Loading version...": "Indlæser version...",
|
||||
"OwnTracks website": "OwnTracks hjemmeside",
|
||||
"OwnTracks documentation": "OwnTracks dokumentation",
|
||||
"OwnTracks on Mastodon": "OwnTracks på Mastodon",
|
||||
"Loading data, please wait...": "Indlæser data, vent venligst...",
|
||||
"Cancel": "Fortryd",
|
||||
"Image of {deviceName}": "Billede af {deviceName}",
|
||||
"Timestamp": "Tidspunkt",
|
||||
"Location": "Position",
|
||||
"Address": "Adresse",
|
||||
"Battery": "Batteri",
|
||||
"Speed": "Hastighed",
|
||||
"Regions:": "Regioner:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "I dag",
|
||||
"Yesterday": "I går",
|
||||
"3 days": "3 dage",
|
||||
"7 days": "7 dage",
|
||||
"15 days": "15 dage",
|
||||
"30 days": "30 dage",
|
||||
"3 months": "3 måneder",
|
||||
"6 months": "6 måneder",
|
||||
"1 year": "1 år"
|
||||
}
|
||||
45
src/locales/de-DE.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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",
|
||||
"Elevation gain / loss": "Höhengewinn / -verlust",
|
||||
"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",
|
||||
"Loading version...": "Version wird abgerufen...",
|
||||
"OwnTracks website": "OwnTracks Webseite",
|
||||
"OwnTracks documentation": "OwnTracks Dokumentation",
|
||||
"OwnTracks on Mastodon": "OwnTracks auf Mastodon",
|
||||
"Loading data, please wait...": "Daten werden geladen, bitte warten...",
|
||||
"Cancel": "Abbrechen",
|
||||
"Image of {deviceName}": "Bild von {deviceName}",
|
||||
"Timestamp": "Zeitstempel",
|
||||
"Location": "Standort",
|
||||
"Address": "Adresse",
|
||||
"Battery": "Akku",
|
||||
"Speed": "Geschwindigkeit",
|
||||
"Regions:": "Regionen:",
|
||||
"WiFi": "WLAN",
|
||||
"Today": "Heute",
|
||||
"Yesterday": "Gestern",
|
||||
"3 days": "3 Tage",
|
||||
"7 days": "7 Tage",
|
||||
"15 days": "15 Tage",
|
||||
"30 days": "30 Tage",
|
||||
"3 months": "3 Monate",
|
||||
"6 months": "6 Monate",
|
||||
"1 year": "1 Jahr"
|
||||
}
|
||||
48
src/locales/en-GB.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Automatically centre 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",
|
||||
"Elevation gain / loss": "Elevation gain / loss",
|
||||
"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",
|
||||
"Show points of interest": "Show points of interest",
|
||||
"Minify JSON": "Minify JSON",
|
||||
"Copy to clipboard": "Copy to clipboard",
|
||||
"Loading version...": "Loading version...",
|
||||
"OwnTracks website": "OwnTracks website",
|
||||
"OwnTracks documentation": "OwnTracks documentation",
|
||||
"OwnTracks on Mastodon": "OwnTracks on Mastodon",
|
||||
"Loading data, please wait...": "Loading data, please wait...",
|
||||
"Cancel": "Cancel",
|
||||
"Image of {deviceName}": "Image of {deviceName}",
|
||||
"Timestamp": "Timestamp",
|
||||
"Location": "Location",
|
||||
"Address": "Address",
|
||||
"Battery": "Battery",
|
||||
"Speed": "Speed",
|
||||
"Regions:": "Regions:",
|
||||
"WiFi": "WiFi",
|
||||
"Select date": "Select date",
|
||||
"Select time": "Select time",
|
||||
"Today": "Today",
|
||||
"Yesterday": "Yesterday",
|
||||
"3 days": "3 days",
|
||||
"7 days": "7 days",
|
||||
"15 days": "15 days",
|
||||
"30 days": "30 days",
|
||||
"3 months": "3 months",
|
||||
"6 months": "6 months",
|
||||
"1 year": "1 year"
|
||||
}
|
||||
48
src/locales/en-US.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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 traveled",
|
||||
"Elevation gain / loss": "Elevation gain / loss",
|
||||
"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",
|
||||
"Show points of interest": "Show points of interest",
|
||||
"Minify JSON": "Minify JSON",
|
||||
"Copy to clipboard": "Copy to clipboard",
|
||||
"Loading version...": "Loading version...",
|
||||
"OwnTracks website": "OwnTracks website",
|
||||
"OwnTracks documentation": "OwnTracks documentation",
|
||||
"OwnTracks on Mastodon": "OwnTracks on Mastodon",
|
||||
"Loading data, please wait...": "Loading data, please wait...",
|
||||
"Cancel": "Cancel",
|
||||
"Image of {deviceName}": "Image of {deviceName}",
|
||||
"Timestamp": "Timestamp",
|
||||
"Location": "Location",
|
||||
"Address": "Address",
|
||||
"Battery": "Battery",
|
||||
"Speed": "Speed",
|
||||
"Regions:": "Regions:",
|
||||
"WiFi": "WiFi",
|
||||
"Select date": "Select date",
|
||||
"Select time": "Select time",
|
||||
"Today": "Today",
|
||||
"Yesterday": "Yesterday",
|
||||
"3 days": "3 days",
|
||||
"7 days": "7 days",
|
||||
"15 days": "15 days",
|
||||
"30 days": "30 days",
|
||||
"3 months": "3 months",
|
||||
"6 months": "6 months",
|
||||
"1 year": "1 year"
|
||||
}
|
||||
45
src/locales/es-ES.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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",
|
||||
"Elevation gain / loss": "Aumento / disminución de la altura",
|
||||
"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",
|
||||
"Loading version...": "Cargando versión...",
|
||||
"OwnTracks website": "OwnTracks - Sitio web",
|
||||
"OwnTracks documentation": "OwnTracks - documentación",
|
||||
"OwnTracks on Mastodon": "OwnTracks en Mastodon",
|
||||
"Loading data, please wait...": "Cargando datos, por favor, espera...",
|
||||
"Cancel": "Cancelar",
|
||||
"Image of {deviceName}": "Imágen de {deviceName}",
|
||||
"Timestamp": "Fecha / Hora",
|
||||
"Location": "Ubicación",
|
||||
"Address": "Dirección",
|
||||
"Battery": "Bateria",
|
||||
"Speed": "Velocidad",
|
||||
"Regions:": "Regiones:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Hoy",
|
||||
"Yesterday": "Ayer",
|
||||
"3 days": "3 días",
|
||||
"7 days": "7 días",
|
||||
"15 days": "15 días",
|
||||
"30 days": "30 días",
|
||||
"3 months": "3 meses",
|
||||
"6 months": "6 meses",
|
||||
"1 year": "1 año"
|
||||
}
|
||||
45
src/locales/fr-FR.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Centrer automatiquement la vue de la carte et zoomer sur les données pertinentes",
|
||||
"Fit view": "Vue d'ensemble",
|
||||
"Layer settings": "Paramètres des couches",
|
||||
"Show/hide layers": "Montrer/cacher certaines couches",
|
||||
"Now": "Maintenant",
|
||||
"Select start date": "Sélectionner une date de début",
|
||||
"to": "à",
|
||||
"Select end date": "Sélectionner une date de fin",
|
||||
"Select user": "Sélectionner un utilisateur",
|
||||
"Show all": "Tout afficher",
|
||||
"Select device": "Sélectionner un appareil",
|
||||
"Distance travelled": "Distance parcourue",
|
||||
"Elevation gain / loss": "Augmentation / diminution de l'altitude",
|
||||
"Information": "Informations",
|
||||
"Show last known locations": "Afficher les dernières localisations connues",
|
||||
"Show location history (line)": "Afficher l'historique de localisation (lignes)",
|
||||
"Show location history (points)": "Afficher l'historique de localisation (points)",
|
||||
"Show location heatmap": "Afficher la carte de fréquentation",
|
||||
"Minify JSON": "Minifier JSON",
|
||||
"Copy to clipboard": "Copier dans le presse-papier",
|
||||
"Loading version...": "Chargement de la version...",
|
||||
"OwnTracks website": "Site d'OwnTracks",
|
||||
"OwnTracks documentation": "Documentation d'OwnTracks",
|
||||
"OwnTracks on Mastodon": "OwnTracks sur Mastodon",
|
||||
"Loading data, please wait...": "Chargement des données, merci de patienter ...",
|
||||
"Cancel": "Annuler",
|
||||
"Image of {deviceName}": "Image de {deviceName}",
|
||||
"Timestamp": "Horodatage",
|
||||
"Location": "Localisation",
|
||||
"Address": "Addresse",
|
||||
"Battery": "Batterie",
|
||||
"Speed": "Vitesse",
|
||||
"Regions:": "Régions:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Aujourd'hui",
|
||||
"Yesterday": "Hier",
|
||||
"3 days": "3 jours",
|
||||
"7 days": "7 jours",
|
||||
"15 days": "15 jours",
|
||||
"30 days": "30 jours",
|
||||
"3 months": "3 mois",
|
||||
"6 months": "6 mois",
|
||||
"1 year": "1 an"
|
||||
}
|
||||
45
src/locales/sk-SK.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Automaticky vystrediť pohľad na mapu a priblížiť na príslušné údaje",
|
||||
"Fit view": "Napasovať pohľad",
|
||||
"Layer settings": "Nastavenia vrstvy",
|
||||
"Show/hide layers": "Zobraziť/skryť vrstvy",
|
||||
"Now": "Teraz",
|
||||
"Select start date": "Zvoliť začiatočný dátum",
|
||||
"to": "do",
|
||||
"Select end date": "Zvoliť konečný dátum",
|
||||
"Select user": "Zvoliť používateľa",
|
||||
"Show all": "Zobraziť všetko",
|
||||
"Select device": "Zvoliť zariadenie",
|
||||
"Distance travelled": "Prejdená vzdialenosť",
|
||||
"Elevation gain / loss": "Výškový výstup / pokles",
|
||||
"Information": "Informácie",
|
||||
"Show last known locations": "Zobraziť posledné známe polohy",
|
||||
"Show location history (line)": "Zobraziť históriu polôh (čiara)",
|
||||
"Show location history (points)": "Zobraziť históriu polôh (body)",
|
||||
"Show location heatmap": "Zobraziť tepelnú mapu polôh",
|
||||
"Minify JSON": "Zostručniť JSON",
|
||||
"Copy to clipboard": "Skopírovať do schránky",
|
||||
"Loading version...": "Nahrávam verziu...",
|
||||
"OwnTracks website": "Web Stránka OwnTracks",
|
||||
"OwnTracks documentation": "Dokumentácia OwnTracks",
|
||||
"OwnTracks on Mastodon": "OwnTracks na Mastodon",
|
||||
"Loading data, please wait...": "Nahrávam údaje, prosím počkajte...",
|
||||
"Cancel": "Zrušiť",
|
||||
"Image of {deviceName}": "Obrázok {deviceName}",
|
||||
"Timestamp": "Časová pečiatka",
|
||||
"Location": "Poloha",
|
||||
"Address": "Adresa",
|
||||
"Battery": "Batéria",
|
||||
"Speed": "Rýchlosť",
|
||||
"Regions:": "Oblasti:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Dnes",
|
||||
"Yesterday": "Včera",
|
||||
"3 days": "3 dni",
|
||||
"7 days": "7 dní",
|
||||
"15 days": "15 dní",
|
||||
"30 days": "30 dní",
|
||||
"3 months": "3 mesiace",
|
||||
"6 months": "6 mesiacov",
|
||||
"1 year": "1 rok"
|
||||
}
|
||||
45
src/locales/tr-TR.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Harita görünümünü otomatik olarak ortalayın ve ilgili verileri yakınlaştırın",
|
||||
"Fit view": "Görünümü sığdır",
|
||||
"Layer settings": "Katman ayarları",
|
||||
"Show/hide layers": "Katmanları göster/gizle",
|
||||
"Now": "Şimdi",
|
||||
"Select start date": "Başlangıç tarihini seçin",
|
||||
"to": "ile",
|
||||
"Select end date": "Bitiş tarihini seçin",
|
||||
"Select user": "Kullanıcı seç",
|
||||
"Show all": "Tümünü göster",
|
||||
"Select device": "Cihaz Seç",
|
||||
"Distance travelled": "Gidilen mesafe",
|
||||
"Elevation gain / loss": "Yükseklik kazancı / kaybı",
|
||||
"Information": "Bilgi",
|
||||
"Show last known locations": "Bilinen son yerleri göster",
|
||||
"Show location history (line)": "Konum geçmişini göster (çizgi)",
|
||||
"Show location history (points)": "Konum geçmişini göster (nokta)",
|
||||
"Show location heatmap": "Konum ısı haritasını göster",
|
||||
"Minify JSON": "JSON'u Küçült",
|
||||
"Copy to clipboard": "Panoya kopyala",
|
||||
"Loading version...": "Versiyon yükleniyor...",
|
||||
"OwnTracks website": "OwnTracks internet sitesi",
|
||||
"OwnTracks documentation": "OwnTracks dokümanı",
|
||||
"OwnTracks on Mastodon": "Mastodon'da OwnTracks",
|
||||
"Loading data, please wait...": "Veriler yükleniyor, lüften bekleyin...",
|
||||
"Cancel": "İptal",
|
||||
"Image of {deviceName}": "{deviceName} resmi",
|
||||
"Timestamp": "Zaman Damgası",
|
||||
"Location": "Konum",
|
||||
"Address": "Adres",
|
||||
"Battery": "Batarya",
|
||||
"Speed": "Hız",
|
||||
"Regions:": "Bölgeler:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Bugün",
|
||||
"Yesterday": "Dün",
|
||||
"3 days": "3 gün",
|
||||
"7 days": "7 gün",
|
||||
"15 days": "15 gün",
|
||||
"30 days": "30 gün",
|
||||
"3 months": "3 ay",
|
||||
"6 months": "6 ay",
|
||||
"1 year": "1 yıl"
|
||||
}
|
||||
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");
|
||||
18
src/router.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Vue from "vue";
|
||||
import Router from "vue-router";
|
||||
import config from "@/config";
|
||||
import Map from "@/views/Map.vue";
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
export default new Router({
|
||||
mode: "history",
|
||||
base: config.router.basePath,
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "map",
|
||||
component: Map,
|
||||
},
|
||||
],
|
||||
});
|
||||
274
src/store/actions.js
Normal file
@@ -0,0 +1,274 @@
|
||||
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 _getTravelStats = (locationHistory) => {
|
||||
const start = Date.now();
|
||||
let distanceTravelled = 0;
|
||||
let elevationGain = 0;
|
||||
let elevationLoss = 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, location.alt ?? 0);
|
||||
if (lastLatLng !== null) {
|
||||
const distance = distanceBetweenCoordinates(lastLatLng, latLng);
|
||||
const elevationChange = latLng.alt - lastLatLng.alt;
|
||||
if (
|
||||
typeof config.map.maxPointDistance === "number" &&
|
||||
config.map.maxPointDistance > 0
|
||||
? // If part of the current group, add to total
|
||||
distance <= config.map.maxPointDistance
|
||||
: // If grouping is disabled, always add to total
|
||||
true
|
||||
) {
|
||||
distanceTravelled += distance;
|
||||
if (elevationChange >= 0) elevationGain += elevationChange;
|
||||
else elevationLoss += -elevationChange;
|
||||
}
|
||||
}
|
||||
lastLatLng = latLng;
|
||||
});
|
||||
});
|
||||
});
|
||||
const end = Date.now();
|
||||
log("PERFORMANCE", () => {
|
||||
const locationHistoryCount = getLocationHistoryCount(locationHistory);
|
||||
const duration = (end - start) / 1000;
|
||||
return (
|
||||
`[_getTravelStats] Took ${duration} seconds to calculate distance ` +
|
||||
`and elevation gain/loss of ${locationHistoryCount} locations`
|
||||
);
|
||||
});
|
||||
return { distanceTravelled, elevationGain, elevationLoss };
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
commit(types.SET_REQUEST_ABORT_CONTROLLER, new AbortController());
|
||||
let locationHistory;
|
||||
try {
|
||||
locationHistory = await api.getLocationHistory(
|
||||
devices,
|
||||
state.startDateTime,
|
||||
state.endDateTime,
|
||||
{ signal: state.requestAbortController.signal }
|
||||
);
|
||||
} catch (error) {
|
||||
return;
|
||||
} finally {
|
||||
commit(types.SET_REQUEST_ABORT_CONTROLLER, null);
|
||||
commit(types.SET_IS_LOADING, false);
|
||||
}
|
||||
commit(types.SET_LOCATION_HISTORY, locationHistory);
|
||||
if (config.showDistanceTravelled) {
|
||||
const { distanceTravelled, elevationGain, elevationLoss } =
|
||||
_getTravelStats(locationHistory);
|
||||
commit(types.SET_DISTANCE_TRAVELLED, distanceTravelled);
|
||||
commit(types.SET_ELEVATION_GAIN, elevationGain);
|
||||
commit(types.SET_ELEVATION_LOSS, elevationLoss);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
41
src/store/index.js
Normal file
@@ -0,0 +1,41 @@
|
||||
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: import.meta.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: 0,
|
||||
elevationGain: 0,
|
||||
elevationLoss: 0,
|
||||
requestAbortController: null,
|
||||
},
|
||||
getters,
|
||||
mutations,
|
||||
actions,
|
||||
});
|
||||
17
src/store/mutation-types.js
Normal file
@@ -0,0 +1,17 @@
|
||||
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";
|
||||
export const SET_ELEVATION_GAIN = "SET_ELEVATION_GAIN";
|
||||
export const SET_ELEVATION_LOSS = "SET_ELEVATION_LOSS";
|
||||
export const SET_REQUEST_ABORT_CONTROLLER = "SET_REQUEST_ABORT_CONTROLLER";
|
||||
55
src/store/mutations.js
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
},
|
||||
[types.SET_ELEVATION_GAIN](state, elevationGain) {
|
||||
state.elevationGain = elevationGain;
|
||||
},
|
||||
[types.SET_ELEVATION_LOSS](state, elevationLoss) {
|
||||
state.elevationLoss = elevationLoss;
|
||||
},
|
||||
[types.SET_REQUEST_ABORT_CONTROLLER](state, requestAbortController) {
|
||||
state.requestAbortController = requestAbortController;
|
||||
},
|
||||
};
|
||||
371
src/styles/_base.scss
Normal file
@@ -0,0 +1,371 @@
|
||||
* {
|
||||
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;
|
||||
|
||||
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,
|
||||
> .mx-datepicker,
|
||||
> .mx-input,
|
||||
> .dropdown {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> .dropdown .dropdown-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mx-datepicker {
|
||||
display: flex;
|
||||
width: auto;
|
||||
|
||||
.mx-datepicker-range {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mx-input-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
.mx-input {
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> 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;
|
||||
}
|
||||
45
src/styles/_datepicker.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
.mx-datepicker {
|
||||
width: 280px;
|
||||
|
||||
.mx-input {
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
text-align: center;
|
||||
height: 33px;
|
||||
padding-right: 0px;
|
||||
padding-left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.mx-datepicker-main {
|
||||
display: flex;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.mx-datepicker-sidebar {
|
||||
flex: 0.7;
|
||||
}
|
||||
|
||||
.mx-datepicker-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx-time {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggle-date-btn {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.mx-icon-calendar {
|
||||
display: none;
|
||||
}
|
||||
91
src/styles/_map.scss
Normal file
@@ -0,0 +1,91 @@
|
||||
.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;
|
||||
|
||||
.face {
|
||||
width: 40px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.leaflet-popup-close-button {
|
||||
color: inherit;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-top: 15px;
|
||||
margin-right: 15px;
|
||||
border-radius: 100%;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/styles/_modal.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
.vm--container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.vm--overlay {
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
.vm--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";
|
||||
100
src/util.js
Normal file
@@ -0,0 +1,100 @@
|
||||
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(0, -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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
271
src/views/Map.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<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"
|
||||
:max-width="controls.scale.maxWidth"
|
||||
:metric="controls.scale.metric"
|
||||
:imperial="controls.scale.imperial"
|
||||
/>
|
||||
<LTileLayer
|
||||
:url="url"
|
||||
:attribution="attribution"
|
||||
:tile-size="tileSize"
|
||||
:options="{ maxNativeZoom, maxZoom, zoomOffset }"
|
||||
/>
|
||||
|
||||
<template v-if="map.layers.line">
|
||||
<LPolyline
|
||||
v-for="(group, i) in filteredLocationHistoryLatLngGroups"
|
||||
:key="i"
|
||||
:lat-lngs="group"
|
||||
v-bind="polyline"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-for="(userDevices, user) in filteredLocationHistory">
|
||||
<template v-for="(deviceLocations, device) in userDevices">
|
||||
<template
|
||||
v-for="(l, n) in deviceLocationsWithNameAndFace(
|
||||
user,
|
||||
device,
|
||||
deviceLocations
|
||||
)"
|
||||
>
|
||||
<LCircleMarker
|
||||
v-if="map.layers.poi && l.poi"
|
||||
:key="`${l.topic}-poi-${n}`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
v-bind="poiMarker"
|
||||
>
|
||||
<LTooltip :options="{ permanent: true }">
|
||||
{{ l.poi }}
|
||||
</LTooltip>
|
||||
</LCircleMarker>
|
||||
<LCircleMarker
|
||||
v-if="map.layers.points"
|
||||
:key="`${l.topic}-location-${n}`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
v-bind="circleMarker"
|
||||
>
|
||||
<LDeviceLocationPopup
|
||||
:user="user"
|
||||
:device="device"
|
||||
:name="l.name"
|
||||
:face="l.face"
|
||||
:timestamp="l.tst"
|
||||
:iso-local="l.isolocal"
|
||||
:time-zone="l.tzname"
|
||||
:lat="l.lat"
|
||||
:lon="l.lon"
|
||||
:alt="l.alt"
|
||||
:battery="l.batt"
|
||||
:speed="l.vel"
|
||||
:regions="l.inregions"
|
||||
:wifi="{ ssid: l.SSID, bssid: l.BSSID }"
|
||||
:address="l.addr"
|
||||
></LDeviceLocationPopup>
|
||||
</LCircleMarker>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<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"
|
||||
:iso-local="l.isolocal"
|
||||
:time-zone="l.tzname"
|
||||
:lat="l.lat"
|
||||
:lon="l.lon"
|
||||
:alt="l.alt"
|
||||
:battery="l.batt"
|
||||
:speed="l.vel"
|
||||
:regions="l.inregions"
|
||||
:wifi="{ ssid: l.SSID, bssid: l.BSSID }"
|
||||
:options="{ className: 'leaflet-popup--for-pin', maxWidth: 400 }"
|
||||
:address="l.addr"
|
||||
/>
|
||||
</LMarker>
|
||||
</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,
|
||||
LTooltip,
|
||||
} 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.vue";
|
||||
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LMap,
|
||||
LTileLayer,
|
||||
LControlScale,
|
||||
LControlZoom,
|
||||
LMarker,
|
||||
LCircleMarker,
|
||||
LCircle,
|
||||
LPolyline,
|
||||
LDeviceLocationPopup,
|
||||
LHeatmap,
|
||||
LTooltip,
|
||||
},
|
||||
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,
|
||||
tileSize: this.$config.map.tileSize,
|
||||
url: this.$config.map.url,
|
||||
zoom: this.$store.state.map.zoom,
|
||||
zoomOffset: this.$config.map.zoomOffset,
|
||||
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,
|
||||
},
|
||||
poiMarker: this.$config.map.poiMarker,
|
||||
polyline: {
|
||||
...this.$config.map.polyline,
|
||||
color: this.$config.map.polyline.color || this.$config.primaryColor,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
"filteredLocationHistory",
|
||||
"filteredLocationHistoryLatLngs",
|
||||
"filteredLocationHistoryLatLngGroups",
|
||||
]),
|
||||
...mapState(["lastLocations", "map"]),
|
||||
},
|
||||
watch: {
|
||||
lastLocations() {
|
||||
if (this.$config.onLocationChange.fitView) {
|
||||
this.fitView();
|
||||
}
|
||||
},
|
||||
filteredLocationHistory() {
|
||||
this.fitView();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$root.$on("fitView", () => {
|
||||
this.fitView();
|
||||
});
|
||||
},
|
||||
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.poi ||
|
||||
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,
|
||||
}));
|
||||
},
|
||||
},
|
||||
};
|
||||
</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;
|
||||
}
|
||||
229
tests/api.test.js
Normal file
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import createFetchMock from "vitest-fetch-mock";
|
||||
|
||||
import * as api from "@/api";
|
||||
|
||||
const fetchMocker = createFetchMock(vi);
|
||||
|
||||
describe("API", () => {
|
||||
beforeEach(() => {
|
||||
fetchMocker.enableMocks();
|
||||
fetchMocker.resetMocks();
|
||||
});
|
||||
|
||||
test("getVersion", async () => {
|
||||
fetchMocker.mockResponse(JSON.stringify({ version: "1.2.3" }));
|
||||
|
||||
const version = await api.getVersion();
|
||||
expect(version).toBe("1.2.3");
|
||||
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/version"
|
||||
);
|
||||
});
|
||||
|
||||
test("getUsers", async () => {
|
||||
fetchMocker.mockResponse(JSON.stringify({ results: ["foo", "bar"] }));
|
||||
|
||||
const users = await api.getUsers();
|
||||
expect(users).toEqual(["foo", "bar"]);
|
||||
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/list"
|
||||
);
|
||||
});
|
||||
|
||||
test("getDevices", async () => {
|
||||
fetchMocker.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(fetchMocker.mock.calls.length).toEqual(2);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/list?user=foo"
|
||||
);
|
||||
expect(fetchMocker.mock.calls[1][0]).toEqual(
|
||||
"http://localhost:3000/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",
|
||||
},
|
||||
];
|
||||
fetchMocker.mockResponse(JSON.stringify(response));
|
||||
|
||||
const lastLocation = await api.getLastLocations();
|
||||
expect(lastLocation).toEqual(response);
|
||||
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/last"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLastLocations with user", async () => {
|
||||
const response = [
|
||||
// Other properties not relevant for testing
|
||||
{
|
||||
username: "foo",
|
||||
device: "phone",
|
||||
},
|
||||
{
|
||||
username: "foo",
|
||||
device: "tablet",
|
||||
},
|
||||
];
|
||||
fetchMocker.mockResponse(JSON.stringify(response));
|
||||
|
||||
const lastLocation = await api.getLastLocations("foo");
|
||||
expect(lastLocation).toEqual(response);
|
||||
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/last?user=foo"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLastLocations with user and device", async () => {
|
||||
const response = [
|
||||
// Other properties not relevant for testing
|
||||
{
|
||||
username: "foo",
|
||||
device: "phone",
|
||||
},
|
||||
];
|
||||
fetchMocker.mockResponse(JSON.stringify(response));
|
||||
|
||||
const lastLocation = await api.getLastLocations("foo", "phone");
|
||||
expect(lastLocation).toEqual(response);
|
||||
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/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,
|
||||
};
|
||||
fetchMocker.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(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLocationHistory", async () => {
|
||||
fetchMocker.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(fetchMocker.mock.calls.length).toEqual(3);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
|
||||
);
|
||||
expect(fetchMocker.mock.calls[1][0]).toEqual(
|
||||
"http://localhost:3000/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=tablet&format=json"
|
||||
);
|
||||
expect(fetchMocker.mock.calls[2][0]).toEqual(
|
||||
"http://localhost:3000/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();
|
||||
133
tests/util.test.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
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:3000/foo");
|
||||
expect(getApiUrl("/foo").href).toBe("http://localhost:3000/foo");
|
||||
expect(getApiUrl("/foo/bar").href).toBe("http://localhost:3000/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"
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
19
vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue2";
|
||||
import version from "vite-plugin-package-version";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: "",
|
||||
plugins: [vue(), version()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(dirname(fileURLToPath(import.meta.url)), "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||