123 Commits

Author SHA1 Message Date
Linus Groh
9e36d31997 Release 2.10.0 2021-11-28 18:04:58 +00:00
Linus Groh
5e37c7f4b8 Sort device location history entries by timestamp
Fixes #67.
2021-11-28 17:52:28 +00:00
Linus Groh
7dda60d457 Ensure all SCSS files are formatted with prettier 2021-11-28 17:33:32 +00:00
Linus Groh
228900ff9f Upgrade vue-js-modal to v2 2021-11-28 17:33:08 +00:00
Linus Groh
129446de1a Upgrade dependencies 2021-11-28 17:31:55 +00:00
Linus Groh
af6c308bd6 Rename master branch to main 2021-11-28 17:31:39 +00:00
Linus Groh
223e19a118 Upgrade dependencies 2021-08-30 16:02:53 +01:00
Linus Groh
1260814309 Upgrade dependencies 2021-08-30 16:02:53 +01:00
Andrew Rowson
cfffbe9472 Add trailing slashes to location blocks (#63)
On my configuration, having a trailing slash on `proxy_pass` but not on
the location block causes the sub-path after the `location` to be
appended to the `proxy_pass` url, causing a double-forward-slash.

E.g. going to `/api/0/list` actually ends up making a call to the proxy
of `http://otrecorder/api//0/list`, which fails for me.
2021-06-28 12:36:16 +01:00
Linus Groh
4031bda2f0 Reformat files with updated prettier 2021-05-28 17:37:35 +01:00
Linus Groh
69094e240e Upgrade dependencies 2021-05-28 17:37:08 +01:00
Linus Groh
dfa7a423fa Release 2.9.0 2021-05-01 22:46:02 +02:00
Linus Groh
411bc10b0b Upgrade dependencies 2021-05-01 22:32:17 +02:00
Linus Groh
a994051940 Upgrade dependencies 2021-04-28 20:30:06 +02:00
Linus Groh
d325543bc6 Replace "OwnTracks UI" with "OwnTracks Frontend"
"OwnTracks UI" was the name I initially used when developing this
separately, but since it's now known as the "OwnTracks frontend", let's
just call it that.
2021-03-24 19:00:35 +01:00
Linus Groh
80d3060fa8 Replace borales/actions-yarn with actions/setup-node 2021-03-24 18:26:52 +01:00
Linus Groh
e6c79ac606 Upgrade dependencies 2021-03-24 18:02:09 +01:00
Linus Groh
0b1271502f Upgrade dependencies 2021-02-24 20:18:56 +01:00
Linus Groh
fdddd8e035 Add cancel button to loading modal 2021-02-24 20:07:47 +01:00
Linus Groh
245c1295e5 Fix JSDoc return type annotations of async functions
These don't return the mentioned type directly, but a promise containing
this type, which has to be await'ed first. This was confusing my IDE's
tslint which suggested await would not be necessary.
2021-02-24 20:07:47 +01:00
Linus Groh
9786487646 Move .distance-travelled SCSS to scoped <style> 2021-02-24 20:07:47 +01:00
Linus Groh
b3529c211c Release 2.8.0 2021-02-19 18:57:19 +01:00
Linus Groh
55178c7cc8 Add "Elevation gain / loss" es-ES and fr-FR translations
Based on help from computers & friends, I'm sure both know better than I
do!
2021-02-19 18:54:27 +01:00
Linus Groh
2fcf2151fa Add elevation gain / loss to distance travelled stats
Closes #51.
2021-02-18 22:05:46 +01:00
Linus Groh
5c6370090f Release 2.7.0 2021-02-14 15:06:35 +01:00
Linus Groh
fc0189e5e2 Use official Docker Buildx action 2021-02-14 15:04:21 +01:00
Linus Groh
04fb50667b Update Dockerfile to use node:14 and nginx:1.18-alpine 2021-02-14 15:04:21 +01:00
Linus Groh
6359b4783c Add missing i18n for "Loading version..."
Hopefully DeepL got the translations for French and Spanish right...
2021-02-14 13:51:34 +01:00
Linus Groh
4679f7fbb7 Add en-GB translations 2021-02-14 13:23:29 +01:00
Linus Groh
b29cd12ed9 Use xx-XX format for translation files and default to en-US
Many languages have different variants, so instead of "en" we should be
using "en-US" - this will make it possible to add slightly different
British English translations, for example.
Note that this was already supported for the `locale` config option, we
were simply discarding the part after the dash when looking for the
right translation file.

Also make sure the en-US translations are actually American English,
I'll add en-GB separately.
2021-02-14 13:21:54 +01:00
Linus Groh
a9026c7a0a Upgrade dependencies 2021-02-14 12:49:46 +01:00
Elu43
27070812a4 Add French translations 2021-02-14 12:49:07 +01:00
Linus Groh
75e79fb0b1 Upgrade dependencies 2021-01-11 19:02:35 +01:00
Linus Groh
4bb9a20787 Fix incomplete Config interface in index.d.ts
Haven't looked at this in a while. :)
2021-01-11 18:53:34 +01:00
Linus Groh
1a47fd1b6c Release 2.6.0 2020-12-29 19:39:37 +01:00
Linus Groh
163e0e3ec7 Upgrade dependencies 2020-12-29 19:21:38 +01:00
Andrew Rowson
8d8664a338 Configure Vue to not assume it's on the web root
Fixes #21.
2020-12-29 19:17:11 +01:00
Linus Groh
045e635c21 Add router.basePath config option for deployments at non-webroot
This is in preparation of configuring Vue with publicPath: "".
2020-12-29 19:17:11 +01:00
dependabot[bot]
7db7837dfd Bump ini from 1.3.5 to 1.3.8 2020-12-24 13:56:38 +01:00
Andrew Rowson
beb522c03e NGINX should listen on IPv6 as well 2020-12-24 13:15:34 +01:00
Linus Groh
658cb6b223 Release 2.5.1 2020-10-27 18:07:21 +00:00
Linus Groh
7ab98be4ad Add tests for baseUrl with trailing slash 2020-10-27 17:58:01 +00:00
Karmanyaah Malhotra
6d4d47b5a1 Fix getApiUrl() for baseUrl with trailing slash 2020-10-27 17:52:07 +00:00
Linus Groh
159470181c Upgrade dependencies 2020-10-15 08:45:08 +02:00
Linus Groh
b53a0be707 Upgrade dependencies 2020-09-20 21:34:43 +01:00
Linus Groh
4e70d3a3ad Update dependencies 2020-09-12 13:34:10 +01:00
Linus Groh
b42f6db024 Release 2.5.0 2020-09-07 21:56:37 +01:00
Linus Groh
3fbf0a2ff1 Upgrade dependencies 2020-09-07 21:46:17 +01:00
Linus Groh
206eb268fa Add onLocationChange.fitView config option
It defaults to false now, which is a change - this seems like a sensible
choice though. I can see how it would be annoying to have the map change
while looking at it just because *something* moved.

Fixes #41.
2020-09-07 21:30:47 +01:00
Linus Groh
d7266f48f1 Upgrade dependencies 2020-07-31 20:44:36 +02:00
Linus Groh
9ec17e3e9c Replace node-sass with sass (dart-sass) 2020-07-22 13:15:35 +02:00
Linus Groh
e320441b5e Upgrade dependencies 2020-07-22 13:11:59 +02:00
Linus Groh
1282d93769 Remove datepicker buttons mobile style overrides
This has been fixed upstream.
2020-06-24 14:49:30 +01:00
Linus Groh
b8661b11fb Upgrade dependencies 2020-06-24 14:44:46 +01:00
Linus Groh
4f5bfefc36 Add more badges 2020-06-14 16:27:12 +01:00
Linus Groh
a1faf8153b Replace Travis CI with GitHub Actions build/lint/test workflows (#39) 2020-06-14 16:11:20 +01:00
Linus Groh
23d73461bc Hardcode owntracks/frontend as DOCKER_IMAGE in docker.yml
As we were using the DOCKER_USERNAME secret rather than "owntracks", the
images all ended up in @jpmens' personal account :)
2020-06-14 15:28:41 +01:00
Linus Groh
90dac4022a Rename main.yml to docker.yml
I'll attempt to switch over from Travis CI to GitHub actions completely,
so "main" is not the best naming choice.
2020-06-14 15:24:58 +01:00
Linus Groh
58b22aeebe Update GitHub action Docker workflow name 2020-06-14 15:23:43 +01:00
wollew
cb79a4de81 Use GitHub Actions to build Docker images for multiple archs (#38)
Co-authored-by: Wolfgang Miller-Reichling <wolfgang.miller-reichling@1und1.de>
2020-06-14 14:26:01 +01:00
Linus Groh
c5f491b6fb Upgrade dependencies 2020-06-09 15:41:09 +01:00
dependabot[bot]
49a9b54a5e Bump websocket-extensions from 0.1.3 to 0.1.4 (#37)
Bumps [websocket-extensions](https://github.com/faye/websocket-extensions-node) from 0.1.3 to 0.1.4.
- [Release notes](https://github.com/faye/websocket-extensions-node/releases)
- [Changelog](https://github.com/faye/websocket-extensions-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/faye/websocket-extensions-node/compare/0.1.3...0.1.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-06-07 14:58:07 +01:00
Linus Groh
c449fcaf21 Update features list in README.md 2020-06-03 21:08:45 +01:00
Linus Groh
1746c359f4 Remove additional screenshots
They don't really add much value.
2020-06-03 20:37:25 +01:00
Linus Groh
6180e52f71 Fix vertical offset of non-pin popups 2020-06-03 20:33:26 +01:00
Linus Groh
0f0f29fcee Show regions for location on popup 2020-06-03 20:28:48 +01:00
Linus Groh
cb0694b032 Release 2.4.0 2020-06-01 18:44:07 +01:00
Linus Groh
d0f2a99302 Upgrade dependencies 2020-06-01 18:39:01 +01:00
Linus Groh
c8b0ec8b9e Upgrade dependencies
This includes a major version bump of ESLint and Prettier, so also some
reformatting.
2020-05-16 13:58:48 +01:00
Linus Groh
906eb2a1b4 Fix typos causing the minAccuracy filter to work incorrectly 2020-05-16 13:50:57 +01:00
Linus Groh
005aab715f Improve wording in docs 2020-05-11 19:22:13 +01:00
Linus Groh
b76cbdc2e6 Add filters.minAccuracy config option
This allows us to ignore location points which do not meet the configured
accuracy requirement.

Closes #35.
2020-05-11 19:15:56 +01:00
Linus Groh
bb87ec01d4 Release 2.3.1 2020-05-09 15:50:52 +01:00
Linus Groh
c3e6b775f9 Re-format config.md 2020-05-09 15:50:01 +01:00
Linus Groh
a9998e8e3b Release 2.3.0 2020-05-09 15:45:19 +01:00
Linus Groh
2c4ead262a Add api.fetchOptions to allow customising fetch() behaviour
This allows sending custom HTTP headers or including cookies in the
request!
2020-05-09 15:40:50 +01:00
Linus Groh
6b1d35be51 Upgrade dependencies 2020-05-09 14:18:14 +01:00
Linus Groh
34cc4895b0 Upgrade dependencies 2020-04-02 19:45:43 +01:00
Linus Groh
57caacb548 Release 2.2.0 2020-03-18 17:56:11 +00:00
Linus Groh
9783b6f27d Upgrade dependencies 2020-03-18 17:55:43 +00:00
Linus Groh
b262ff602c Improve mobile layout further
- Reduce header paddings
- Align buttons/dropdowns
2020-03-18 17:50:49 +00:00
Linus Groh
458658865e Release 2.1.0 2020-03-18 00:35:24 +00:00
Linus Groh
e744e2c001 Make it usable on mobile
Closes #19
2020-03-18 00:28:11 +00:00
Linus Groh
feff6d5272 Upgrade dependencies 2020-03-14 16:17:31 +00:00
Linus Groh
7b88102c2b Merge pull request #28 from owntracks/dependabot/npm_and_yarn/acorn-5.7.4
Bump acorn from 5.7.3 to 5.7.4
2020-03-14 16:14:03 +00:00
dependabot[bot]
a2c7974f38 Bump acorn from 5.7.3 to 5.7.4
Bumps [acorn](https://github.com/acornjs/acorn) from 5.7.3 to 5.7.4.
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/5.7.3...5.7.4)

Signed-off-by: dependabot[bot] <support@github.com>
2020-03-14 09:44:52 +00:00
Linus Groh
b3fdf1eabe Improve verbose mode logging
- Support passing a log message function to `logging.log` for lazy
  evaluation
- Log results (or at least lengths) of all API functions
- Log merged config during initialisation
- Log distance calculation duration
2020-03-07 00:22:56 +00:00
Linus Groh
1482833e05 Fix a few parameter type definitions 2020-03-07 00:14:59 +00:00
Linus Groh
04fa3392f0 Fix typo in CHANGELOG.md 2020-03-03 14:05:08 +00:00
Linus Groh
a4334e5273 Fix manual installation instructions in README.md
`yarn install --production` does not actually install all dependencies
required for building - maybe we have to move some `devDependencies` to
`dependencies`, but for now this should do the trick.
2020-03-03 13:40:15 +00:00
Linus Groh
806526380d Replace default Leaflet marker with a custom one
Closes #2
2020-03-03 08:11:45 +00:00
Linus Groh
2e63f01438 Release 2.0.0 🎉 2020-03-01 21:16:59 +00:00
Linus Groh
fb97cd080f Release 2.0.0-beta.11 2020-03-01 21:11:33 +00:00
Linus Groh
f2d461d019 Merge pull request #20 from owntracks/clean-up-the-typing-mess
Clean up the typing mess
2020-03-01 20:59:36 +00:00
Linus Groh
798a0af97d Update function documentations 2020-03-01 20:56:28 +00:00
Linus Groh
3c92a77847 Fix typo in OTLocation.inregions comment 2020-03-01 20:55:52 +00:00
Linus Groh
b252a6580a Use TypeScript interface for websocket calllback 2020-03-01 20:53:38 +00:00
Linus Groh
9ce9933d11 Remove unused properties from Config interface 2020-03-01 20:34:21 +00:00
Linus Groh
f22db6301b Merge branch 'master' into clean-up-the-typing-mess 2020-03-01 20:33:00 +00:00
Linus Groh
3b18ab58ed Replace map initial center/zoom config with auto fitting
Closes #23
2020-03-01 20:28:15 +00:00
Linus Groh
bb81daaea4 Upgrade dependencies 2020-03-01 19:56:57 +00:00
Linus Groh
f491d63eb9 Mention Spanish translations in config docs 2020-02-09 11:07:35 +00:00
Linus Groh
e923fdc6c7 Merge pull request #25 from dtorner/patch-2
Create es.json
2020-02-09 11:00:30 +00:00
dtorner
b1ce1297ed Create es.json 2020-02-09 09:47:11 +01:00
Linus Groh
f4262efaa4 Change distance travelled label to title
See https://github.com/owntracks/frontend/issues/22#issuecomment-583698919
2020-02-08 11:50:48 +00:00
Linus Groh
a2109a5802 Fix humanReadableDistance JSDoc
That's what you get from copy & paste...
2020-02-07 21:51:39 +00:00
Linus Groh
00fbb7cd7c Release 2.0.0-beta.10 2020-02-07 21:42:03 +00:00
Linus Groh
4078597f7a Add "distance travelled" feature 2020-02-07 21:20:38 +00:00
Linus Groh
8dc9611a77 Release 2.0.0-beta.9 2020-02-06 21:30:41 +00:00
Linus Groh
c1f58c992e Support locale with language and region part 2020-02-06 21:26:21 +00:00
Linus Groh
6631929d6f Update screenshot 2020-02-06 21:13:11 +00:00
Linus Groh
36281db2e3 Add .github/FUNDING.yml 2020-02-06 20:35:08 +00:00
Linus Groh
5a8d261943 Fix typo in docs/config.md 2020-02-06 20:23:08 +00:00
Linus Groh
7b83349dc8 Improve CHANGELOG.md 2020-02-06 20:22:48 +00:00
Linus Groh
bc3670df99 Release 2.0.0-beta.8 2020-01-26 00:57:08 +00:00
Linus Groh
95613753a9 Enable ESLint max-len rule 2020-01-26 00:49:15 +00:00
Linus Groh
cfa3052a0a Show name and face on location history popups 2020-01-26 00:40:30 +00:00
Linus Groh
0bd84f4de5 s/@return/@returns 2020-01-26 00:38:36 +00:00
Linus Groh
85e51643bf Add missing alt/title to device face image 2020-01-25 23:37:41 +00:00
Linus Groh
6cbdf30580 Use computed prop for device name in location popup 2020-01-25 23:33:10 +00:00
Linus Groh
a20fbf7e10 Remove all @typedef definitions 2020-01-24 21:02:24 +00:00
Linus Groh
d2eafd5a4a Merge branch 'master' into clean-up-the-typing-mess 2020-01-24 20:43:57 +00:00
Linus Groh
871f9f0cb2 Add index.d.ts 2019-12-15 20:59:57 +00:00
52 changed files with 5325 additions and 4530 deletions

View File

@@ -7,6 +7,12 @@ module.exports = {
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",
{

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
liberapay: owntracks.org

14
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
name: Build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
- run: yarn install
- run: yarn build

79
.github/workflows/docker.yml vendored Normal file
View 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
View File

@@ -0,0 +1,16 @@
name: Lint
on: [push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
- run: yarn install
- run: yarn lint:js
- run: yarn lint:md
- run: yarn lint:scss

14
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
name: Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
- run: yarn install
- run: yarn test

View File

@@ -1,8 +0,0 @@
language: node_js
node_js:
- 10
cache: yarn
script:
- yarn lint:js
- yarn lint:md
- yarn test

View File

@@ -1,5 +1,115 @@
# Changelog
Dates are in UTC.
## 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.
@@ -14,19 +124,19 @@ This release doesn't really affect end-users but greatly improves the developmen
## 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
- 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
- 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
- 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
@@ -37,7 +147,7 @@ This release doesn't really affect end-users but greatly improves the developmen
- Add i18 support (currently English and German, `locale` config option)
- Add custom checkbox focus style
- Fix layer dropdown issues
- Fix layer dropdown issues ([#1](https://github.com/owntracks/frontend/issues/1))
- Fix checkbox style issues
- Fix hover/focus inconsistencies
- Fix Docker image labels
@@ -69,21 +179,21 @@ This release doesn't really affect end-users but greatly improves the developmen
- 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 toeasily use a production instance of the OwnTracks recorder in development.
- Add unit tests for util and API functions.
- 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)).
- 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)
- 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)

View File

@@ -1,11 +1,14 @@
# OwnTracks UI
# OwnTracks Frontend
![Version](https://img.shields.io/github/package-json/v/owntracks/frontend)
[![Docker Pulls](https://img.shields.io/docker/pulls/owntracks/frontend)](https://hub.docker.com/r/owntracks/frontend)
[![Build Status](https://travis-ci.org/owntracks/frontend.svg?branch=master)](https://travis-ci.org/owntracks/frontend)
[![License](https://img.shields.io/github/license/owntracks/frontend?color=d63e97)](https://github.com/owntracks/frontend/blob/master/LICENSE)
[![Build](https://github.com/owntracks/frontend/workflows/Build/badge.svg)](https://github.com/owntracks/frontend/actions?query=workflow%3ABuild+branch%3Amain)
[![Tests](https://github.com/owntracks/frontend/workflows/Tests/badge.svg)](https://github.com/owntracks/frontend/actions?query=workflow%3ATests+branch%3Amain)
[![Lint](https://github.com/owntracks/frontend/workflows/Lint/badge.svg)](https://github.com/owntracks/frontend/actions?query=workflow%3ALint+branch%3Amain)
[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier)
[![License](https://img.shields.io/github/license/owntracks/frontend?color=d63e97)](https://github.com/owntracks/frontend/blob/main/LICENSE)
![OwnTracks UI](https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/owntracks-ui.png)
![Screenshot](https://raw.githubusercontent.com/owntracks/frontend/main/docs/images/screenshot.png)
## Introduction
@@ -13,20 +16,21 @@ This is a web interface for [OwnTracks](https://github.com/owntracks/recorder) b
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.
![Map features](https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/map-features.png)
![Map features](https://raw.githubusercontent.com/owntracks/frontend/main/docs/images/map-features.png)
## Features
- Last known (i.e. live) locations:
- Accuracy visualization (circle)
- Device friendly name and icon
- Detailed information (if available): time, latitude, longitude, height, battery and
speed
- 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 range
- Filter by user and device
- Display data in a specific date and time range
- Filter by user or specific device
- Calculation of distance travelled
- Download selected location data as JSON
- Highly customisable
@@ -49,7 +53,7 @@ compose config, and the service is named `otrecorder`):
version: "3"
services:
owntracks-ui:
owntracks-frontend:
image: owntracks/frontend
ports:
- 80:80
@@ -77,7 +81,7 @@ directory as `docker-compose.yml`)
### Manually
- Run `yarn install --production` to install dependencies
- Run `yarn install` to install dependencies
- Run `yarn build` to compile and minify for production
- Copy the content of the `dist/` directory to your webroot
@@ -97,8 +101,10 @@ See [`docs/config.md`](docs/config.md) for all available options.
- Run `yarn serve` to compile for development and start the hot-reload server
- Run `yarn lint:js` to lint JavaScript/Vue files
- Run `yarn lint:md` to lint Markdown files
- Run `yarn lint:scss` to lint SCSS files
- Run `yarn format:js` to format JavaScript/Vue files
- Run `yarn format:md` to format Markdown files
- Run `yarn format:scss` to format SCSS files
- Run `yarn test` to run unit tests
### CORS-Proxy
@@ -138,24 +144,12 @@ unused i18n entries, run:
$ yarn i18n:report
```
To add a new locale, copy `en.json` to `<locale>.json` in [`src/locales`](src/locales)
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.
## Screenshots
<p align="center">
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/loading.gif" alt="Loading...">
<br>
<br>
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/downloader.png" alt="Download location data">
<br>
<br>
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/info.png" alt="Info">
</p>
## Contributing
Please feel free to open an issue and discuss your ideas and report bugs. If you think

View File

@@ -1,13 +1,13 @@
FROM node:10 as build
FROM node:14 as build
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install
COPY . ./
RUN yarn build
FROM nginx:1.17-alpine
LABEL version="2.0.0-beta.7"
LABEL description="OwnTracks UI"
FROM nginx:1.18-alpine
LABEL version="2.10.0"
LABEL description="OwnTracks Frontend"
LABEL maintainer="Linus Groh <mail@linusgroh.de>"
ENV LISTEN_PORT=80 \
SERVER_HOST=otrecorder \

View File

@@ -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;

View File

@@ -24,14 +24,14 @@ window.owntracks.config = {};
- `api`
- [`baseUrl`](#apibaseurl)
- [`fetchOptions`](#apifetchoptions)
- [`endDateTime`](#enddatetime)
- `filters`
- [`minAccuracy`](#filtersminaccuracy)
- [`ignorePingLocation`](#ignorepinglocation)
- [`locale`](#locale)
- `map`
- [`attribution`](#mapattribution)
- `center`
- [`lat`](#mapcenterlat)
- [`lng`](#mapcenterlng)
- [`circle`](#mapcircle)
- [`circleMarker`](#mapcirclemarker)
- `controls`
@@ -59,12 +59,15 @@ window.owntracks.config = {};
- [`maxZoom`](#mapmaxzoom)
- [`polyline`](#mappolyline)
- [`url`](#mapurl)
- [`zoom`](#mapzoom)
- `onLocationChange`
- [`fitView`](#onlocationchangefitview)
- [`reloadHistory`](#onlocationchangereloadhistory)
- [`primaryColor`](#primarycolor)
- `router`
- [`basePath`](#routerbasepath)
- [`selectedDevice`](#selecteddevice)
- [`selectedUser`](#selecteduser)
- [`showDistanceTravelled`](#showdistancetravelled)
- [`startDateTime`](#startdatetime)
- [`verbose`](#verbose)
@@ -79,16 +82,36 @@ Base URL for the recorder's HTTP and WebSocket API. Keep CORS in mind.
// API requests will be made to https://owntracks.example.com/api/0/...
window.owntracks.config = {
api: {
baseUrl: "https://owntracks.example.com"
}
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/"
}
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",
},
},
};
```
@@ -102,7 +125,27 @@ Initial end date and time (browser timezone) for fetched data.
```js
// Data will be fetched up to 1970-01-01
window.owntracks.config = {
endDateTime: new Date(1970, 1, 1)
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,
},
};
```
@@ -117,16 +160,28 @@ Remove the `ping/ping` location from the fetched data. This is useful when using
```js
// Don't ignore ping/ping location. Not sure why you'd do this :)
window.owntracks.config = {
ignorePingLocation: false
ignorePingLocation: false,
};
```
### `locale`
The language to use for the user interface. Available: `de` (German), `en` (English).
The locale to use for the user interface, this affects the language and date/time
formats.
Available languages:
- `de-DE` (Standard German)
- `en-GB` (British English)
- `en-US` (American English)
- `es-ES` (Castilian Spanish)
- `fr-FR` (Standard French)
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"`
- Default: `"en-US"`
### `map.attribution`
@@ -139,25 +194,11 @@ Attribution for map tiles.
// Make sure to add proper attribution!
window.owntracks.config = {
map: {
attribution: "Map tiles &copy; MyTileServerProvider"
}
attribution: "Map tiles &copy; MyTileServerProvider",
},
};
```
### `map.center.lat`
Initial map center latitude.
- Type: [`Number`]
- Default: `0`
### `map.center.lng`
Initial map center longitude.
- Type: [`Number`]
- Default: `0`
### `map.circle`
Location accuracy indicator configuation. `color` and `fillColor` default to
@@ -321,8 +362,8 @@ splitting into separate lines.
// Don't connect points with a distance of more than 1km
window.owntracks.config = {
map: {
maxPointDistance: 1000
}
maxPointDistance: 1000,
},
};
```
@@ -362,18 +403,19 @@ and [this Wikipedia article](https://en.wikipedia.org/wiki/Tiled_web_map).
// Use dark HDPI tiles from Mapbox
window.owntracks.config = {
map: {
url:
"https://api.mapbox.com/v4/mapbox.dark/{z}/{x}/{y}@2x.png?access_token=xxxxxxxxxxxxxxxx"
}
url: "https://api.mapbox.com/v4/mapbox.dark/{z}/{x}/{y}@2x.png?access_token=xxxxxxxxxxxxxxxx",
},
};
```
### `map.zoom`
### `onLocationChange.fitView`
Initial map zoom level.
Whether to re-fit the map's content into view or not when a location update is received.
- Type: [`Number`]
- Default: `19`
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`
@@ -393,14 +435,30 @@ Primary color for the user interface (navigation bar and various map elements).
```js
// Set the UI's primary color to 'rebeccapurple'
window.owntracks.config = {
primaryColor: "rebeccapurple"
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`;
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.
@@ -412,7 +470,7 @@ amount of data fetched after page load.
// Select the device 'phone' from user 'foo' by default
window.owntracks.config = {
selectedUser: "foo",
selectedDevice: "phone"
selectedDevice: "phone",
};
```
@@ -429,10 +487,22 @@ amount of data fetched after page load.
```js
// Select all devices from user 'foo' by default
window.owntracks.config = {
selectedUser: "foo"
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.
@@ -446,7 +516,7 @@ Initial start date and time (browser timezone) for fetched data.
startDateTime.setHours(0, 0, 0, 0);
startDateTime.setDate(1);
window.owntracks.config = {
startDateTime
startDateTime,
};
```
@@ -463,3 +533,4 @@ Whether to enable verbose mode or not.
[`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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

BIN
docs/images/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "owntracks-ui",
"version": "2.0.0-beta.7",
"name": "owntracks-frontend",
"version": "2.10.0",
"author": {
"name": "Linus Groh",
"email": "mail@linusgroh.de"
@@ -11,49 +11,53 @@
"cors-proxy": "node scripts/corsProxy.js",
"format:js": "vue-cli-service lint",
"format:md": "prettier --write '{*.md,docs/**/*.md,src/**/*.md}'",
"format:scss": "prettier --write 'src/**/*.scss'",
"i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales './src/locales/**/*.json'",
"lint:js": "vue-cli-service lint --no-fix",
"lint:md": "prettier --check '{*.md,docs/**/*.md,src/**/*.md}'",
"lint:scss": "prettier --check 'src/**/*.scss'",
"test": "vue-cli-service test:unit"
},
"dependencies": {
"clipboard-copy": "^3.1.0",
"core-js": "^3.6.4",
"clipboard-copy": "^4.0.1",
"core-js": "^3.19.1",
"deepmerge": "^4.2.2",
"leaflet": "^1.6.0",
"leaflet": "^1.7.1",
"leaflet.heat": "^0.2.0",
"moment": "^2.24.0",
"vue": "^2.6.11",
"vue-ctk-date-time-picker": "^2.4.0",
"vue-feather-icons": "^5.0.0",
"vue-i18n": "^8.15.3",
"vue-js-modal": "^1.3.31",
"moment": "^2.29.1",
"vue": "^2.6.14",
"vue-ctk-date-time-picker": "^2.5.0",
"vue-feather-icons": "^5.1.0",
"vue-i18n": "^8.26.7",
"vue-js-modal": "^2.0.1",
"vue-mq": "^1.0.1",
"vue-outside-events": "^1.1.3",
"vue-router": "^3.1.5",
"vue2-leaflet": "^2.4.2",
"vuex": "^3.1.2"
"vue-router": "^3.5.3",
"vue2-leaflet": "^2.7.1",
"vuex": "^3.6.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.1.2",
"@vue/cli-plugin-eslint": "^4.1.2",
"@vue/cli-plugin-unit-jest": "^4.1.2",
"@vue/cli-service": "^4.1.2",
"@vue/cli-plugin-babel": "~4.5.15",
"@vue/cli-plugin-eslint": "~4.5.15",
"@vue/cli-plugin-unit-jest": "~4.5.15",
"@vue/cli-service": "~4.5.15",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/test-utils": "1.0.0-beta.31",
"@vue/test-utils": "1.3.0",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^25.1.0",
"cors-anywhere": "^0.4.1",
"eslint": "^6.8.0",
"eslint-plugin-prettier": "^3.1.2",
"eslint-plugin-vue": "^6.1.2",
"jest-fetch-mock": "^3.0.1",
"lint-staged": "^10.0.2",
"moment-locales-webpack-plugin": "^1.1.2",
"node-sass": "^4.13.1",
"sass-loader": "^8.0.2",
"vue-cli-plugin-i18n": "^0.6.1",
"vue-template-compiler": "^2.6.11"
"babel-eslint": "^10.1.0",
"babel-jest": "^27.3.1",
"cors-anywhere": "^0.4.4",
"eslint": "^7.32.0",
"eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-vue": "^7.17.0",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^12.1.2",
"moment-locales-webpack-plugin": "^1.2.0",
"prettier": "^2.5.0",
"sass": "^1.43.5",
"sass-loader": "^10.1.1",
"vue-cli-plugin-i18n": "~2.3.1",
"vue-template-compiler": "^2.6.14"
},
"license": "MIT",
"repository": {

View File

@@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>OwnTracks UI</title>
<title>OwnTracks Frontend</title>
</head>
<body>
<noscript>

View File

@@ -34,7 +34,7 @@ export default {
this.populateStateFromQuery(this.$route.query);
this.loadData();
// Update URL query params when relevant values changes
this.$store.subscribe(mutation => {
this.$store.subscribe((mutation) => {
if (
[
types.SET_SELECTED_USER,
@@ -73,7 +73,7 @@ export default {
selectedDevice: device,
} = this.$store.state;
const activeLayers = Object.keys(map.layers).filter(
key => map.layers[key] === true
(key) => map.layers[key] === true
);
const query = {
lat: map.center.lat,

View File

@@ -1,72 +1,84 @@
import config from "@/config";
import { log, logLevels } from "@/logging";
import { getApiUrl } from "@/util";
/** @typedef {import("./types").QueryParams} QueryParams */
/** @typedef {import("./types").User} User */
/** @typedef {import("./types").Device} Device */
/** @typedef {import("./types").LastLocation} LastLocation */
/** @typedef {import("./types").LocationHistory} LocationHistory */
/**
* Callback for new WebSocket location messages.
*
* @callback webSocketLocationCallback
*/
import { getApiUrl, getLocationHistoryCount } from "@/util";
/**
* Fetch an API resource.
*
* @param {String} path API resource path
* @param {QueryParams} [params] Query parameters
* @return {Promise} Promise returned by the fetch function
* @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 = {}) => {
const fetchApi = (path, params = {}, fetchOptions = {}) => {
const url = getApiUrl(path);
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
Object.keys(params).forEach((key) => url.searchParams.set(key, params[key]));
log("HTTP", `GET ${url.href}`);
return fetch(url.href).catch(error => log("HTTP", error, logLevels.ERROR));
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.
*
* @return {String} 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.
*
* @return {Array.<User>} Array of usernames
* @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 {Array.<User>} users Array of usernames
* @return {Object.<User, Array.<Device>>} Object mapping each username to an array of device names
* @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 => {
export const getDevices = async (users) => {
const devices = {};
await Promise.all(
users.map(async user => {
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;
};
@@ -75,7 +87,7 @@ export const getDevices = async users => {
*
* @param {User} [user] Get last locations of all devices from this user
* @param {Device} [device] Get last location of specific device
* @return {Array.<LastLocation>} Array of last location objects
* @returns {Promise<OTLocation[]>} Array of last location objects
*/
export const getLastLocations = async (user, device) => {
const params = {};
@@ -87,7 +99,12 @@ export const getLastLocations = async (user, device) => {
}
const response = await fetchApi("/api/0/last", params);
const json = await response.json();
return json;
const lastLocations = json;
log(
"API",
() => `[getLastLocations] Fetched ${lastLocations.length} last locations`
);
return lastLocations;
};
/**
@@ -97,59 +114,88 @@ export const getLastLocations = async (user, device) => {
* @param {Device} device Device name
* @param {String} start Start date and time in UTC
* @param {String} end End date and time in UTC
* @return {LocationHistory} Array of location history objects
* @param {Object} [fetchOptions] fetch() options
* @returns {Promise<OTLocation[]>} Array of location history objects
*/
export const getUserDeviceLocationHistory = async (
user,
device,
start,
end
end,
fetchOptions
) => {
const response = await fetchApi("/api/0/locations", {
from: start,
to: end,
user,
device,
format: "json",
});
const response = await fetchApi(
"/api/0/locations",
{
from: start,
to: end,
user,
device,
format: "json",
},
fetchOptions
);
const json = await response.json();
return json.data;
// 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 {Object.<User, Array.<Device>>} devices Devices of which the history should be fetched
* @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
* @return {Object.<User, Object.<Device, LocationHistory>>} Array of location history objects
* @param {Object} [fetchOptions] fetch() options
* @returns {Promise<LocationHistory>} Location history
*/
export const getLocationHistory = async (devices, start, end) => {
export const getLocationHistory = async (devices, start, end, fetchOptions) => {
const locationHistory = {};
await Promise.all(
Object.keys(devices).map(async user => {
Object.keys(devices).map(async (user) => {
locationHistory[user] = {};
await Promise.all(
devices[user].map(async device => {
devices[user].map(async (device) => {
locationHistory[user][device] = await getUserDeviceLocationHistory(
user,
device,
start,
end
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.
* Connect to the WebSocket API, reconnect when necessary and handle received
* messages.
*
* @param {webSocketLocationCallback} [callback] Callback for location messages
* @param {WebSocketLocationCallback} [callback] Callback for location messages
*/
export const connectWebsocket = async callback => {
export const connectWebsocket = async (callback) => {
let url = getApiUrl("/ws/last");
url.protocol = url.protocol.replace("http", "ws");
url = url.href;
@@ -159,16 +205,17 @@ export const connectWebsocket = async callback => {
log("WS", "Connected");
ws.send("LAST");
};
ws.onclose = event => {
ws.onclose = (event) => {
log(
"WS",
`Disconnected unexpectedly (reason: ${event.reason ||
"unknown"}). Reconnecting in one second.`,
`Disconnected unexpectedly (reason: ${
event.reason || "unknown"
}). Reconnecting in one second.`,
logLevels.WARNING
);
setTimeout(connectWebsocket, 1000);
};
ws.onmessage = async msg => {
ws.onmessage = async (msg) => {
if (msg.data) {
try {
const data = JSON.parse(msg.data);

View File

@@ -1,7 +1,25 @@
<template>
<header>
<nav>
<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="
@@ -46,7 +64,7 @@
:title="$t('Select start date')"
/>
</VueCtkDateTimePicker>
{{ $t("to") }}
<span>{{ $t("to") }}</span>
<VueCtkDateTimePicker
v-model="endDateTime"
:format="DATE_TIME_FORMAT"
@@ -97,7 +115,23 @@
</select>
</div>
</nav>
<nav class="nav-shrink">
<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"
@@ -124,14 +158,30 @@
</header>
</template>
<style lang="scss" scoped>
.distance-travelled {
text-align: right;
line-height: 1.2;
.feather {
margin-top: 3px;
margin-right: 0 !important;
}
}
</style>
<script>
import moment from "moment";
import { mapActions, mapGetters, mapMutations, mapState } from "vuex";
import {
ArrowDownIcon,
ArrowUpIcon,
CalendarIcon,
CrosshairIcon,
DownloadIcon,
InfoIcon,
LayersIcon,
MenuIcon,
SmartphoneIcon,
UserIcon,
} from "vue-feather-icons";
@@ -141,13 +191,18 @@ import "vue-ctk-date-time-picker/dist/vue-ctk-date-time-picker.css";
import Dropdown from "@/components/Dropdown";
import { DATE_TIME_FORMAT } from "@/constants";
import * as types from "@/store/mutation-types";
import { humanReadableDistance } from "@/util";
export default {
components: {
ArrowDownIcon,
ArrowUpIcon,
CalendarIcon,
CrosshairIcon,
DownloadIcon,
InfoIcon,
LayersIcon,
MenuIcon,
SmartphoneIcon,
UserIcon,
VueCtkDateTimePicker,
@@ -162,10 +217,18 @@ export default {
{ layer: "points", label: this.$t("Show location history (points)") },
{ layer: "heatmap", label: this.$t("Show location heatmap") },
],
showMobileNav: false,
};
},
computed: {
...mapState(["users", "devices", "map"]),
...mapState([
"users",
"devices",
"map",
"distanceTravelled",
"elevationGain",
"elevationLoss",
]),
selectedUser: {
get() {
return this.$store.state.selectedUser;
@@ -191,9 +254,7 @@ export default {
},
set(value) {
this.setStartDateTime(
moment(value, DATE_TIME_FORMAT)
.utc()
.format(DATE_TIME_FORMAT)
moment(value, DATE_TIME_FORMAT).utc().format(DATE_TIME_FORMAT)
);
},
},
@@ -224,6 +285,7 @@ export default {
"setStartDateTime",
"setEndDateTime",
]),
humanReadableDistance,
},
};
</script>

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

View File

@@ -1,9 +1,13 @@
<template>
<LPopup>
<div v-if="name" class="device">{{ name }}</div>
<div v-else class="device">{{ user }}/{{ device }}</div>
<LPopup :options="options">
<div class="device">{{ deviceName }}</div>
<div class="wrapper">
<img v-if="face" :src="faceImageDataURI" alt="" />
<img
v-if="face"
:src="faceImageDataURI"
:alt="$t('Image of {deviceName}', { deviceName })"
:title="$t('Image of {deviceName}', { deviceName })"
/>
<ul class="info-list">
<li :title="$t('Timestamp')">
<ClockIcon size="1x" aria-hidden="true" role="img" />
@@ -31,6 +35,10 @@
</li>
</ul>
</div>
<div v-if="regions.length" class="regions">
{{ $t("Regions:") }}
{{ regions.join(", ") }}
</div>
</LPopup>
</template>
@@ -52,6 +60,11 @@
margin-right: 20px;
}
}
.regions {
border-top: 1px solid var(--color-separator);
margin-top: 15px;
padding-top: 15px;
}
</style>
<script>
@@ -119,16 +132,34 @@ export default {
type: Number,
default: null,
},
regions: {
type: Array,
default: () => [],
},
options: {
type: Object,
default: () => {},
},
},
computed: {
/**
* Return the face image as a data URI string which can be used for an image's src attribute
* Return the face image as a data URI string which can be used for an
* image's src attribute.
*
* @return {String} base64-encoded face image data URI
* @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>

View File

@@ -94,7 +94,7 @@ export default {
this.parentContainer.addLayer(this, !this.visible);
this.$watch(
"latLng",
newVal => {
(newVal) => {
this.mapObject.setLatLngs(newVal);
},
{ deep: true }

View File

@@ -3,17 +3,13 @@
<ul class="info-list">
<li>
<GithubIcon size="1x" aria-hidden="true" role="img" />
<a href="https://github.com/owntracks/frontend">
owntracks/frontend
</a>
<a href="https://github.com/owntracks/frontend">owntracks/frontend</a>
({{ frontendVersion }})
</li>
<li>
<GithubIcon size="1x" aria-hidden="true" role="img" />
<a href="https://github.com/owntracks/recorder">
owntracks/recorder
</a>
({{ recorderVersion || "Loading version..." }})
<a href="https://github.com/owntracks/recorder">owntracks/recorder</a>
({{ recorderVersion || $t("Loading version...") }})
</li>
<li>
<GlobeIcon size="1x" aria-hidden="true" role="img" />

View File

@@ -1,16 +1,29 @@
<template>
<modal name="loading" :click-to-close="false" adaptive>
<LoaderIcon class="loader" size="1.5x" />
<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>
<style scoped>
.loader {
.loader-icon {
animation: spinning 2s linear infinite;
margin-right: 5px;
}
.button-cancel {
display: block;
margin: 20px auto 0;
}
@keyframes spinning {
from {
transform: rotate(0deg);
@@ -22,11 +35,15 @@
</style>
<script>
import { mapState } from "vuex";
import { LoaderIcon } from "vue-feather-icons";
export default {
components: {
LoaderIcon,
},
computed: {
...mapState(["requestAbortController"]),
},
};
</script>

View File

@@ -10,17 +10,17 @@ 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",
locale: "en-US",
map: {
attribution:
'&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
center: {
lat: 0,
lng: 0,
},
circle: {
color: null,
fillColor: null,
@@ -65,14 +65,18 @@ const DEFAULT_CONFIG = {
fillColor: "transparent",
},
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
zoom: 19,
},
onLocationChange: {
fitView: false,
reloadHistory: false,
},
primaryColor: "#3f51b5",
router: {
basePath: "/",
},
selectedDevice: null,
selectedUser: null,
showDistanceTravelled: true,
startDateTime,
verbose: false,
};

View File

@@ -7,7 +7,7 @@ Vue.use(VueI18n);
const locales = require.context("./locales", true, /[A-Za-z0-9-_,\s]+\.json$/i);
const messages = {};
locales.keys().forEach(key => {
locales.keys().forEach((key) => {
const matched = key.match(/([A-Za-z0-9-_]+)\./i);
if (matched && matched.length > 1) {
const locale = matched[1];
@@ -17,7 +17,7 @@ locales.keys().forEach(key => {
export default new VueI18n({
locale: config.locale,
fallbackLocale: "en",
fallbackLocale: "en-US",
formatFallbackMessages: true,
messages,
});

232
src/index.d.ts vendored Normal file
View File

@@ -0,0 +1,232 @@
/** 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;
points: boolean;
};
maxNativeZoom: number;
maxPointDistance: number | null;
maxZoom: number;
polyline: {
color: OptionalColor;
fillColor: OptionalColor;
};
url: string;
};
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;
points: boolean;
};
zoom: number;
};
}
/**
* A location object as returned by the OwnTracks recorder.
* https://owntracks.org/booklet/tech/json/#_typelocation
*/
interface OTLocation {
_http: boolean;
/**
* In this case always "location"
* https://owntracks.org/booklet/tech/json/#types
*/
_type: string;
/** Accuracy in meters */
acc?: number;
/** Altitude above sea level in meters */
alt?: number;
/** Device battery level in percent */
batt?: number;
/**
* Battery status (iOS only)
*
* - `0` = unknown
* - `1` = unplugged
* - `2` = charging
* - `3` = full
*/
bs?: number;
/** Course over ground in degrees (iOS only) */
cog?: number;
/**
* Internet connectivity status (route to host) when the message is created
*
* - `"w"` = phone is connected to a WiFi connection
* - `"o"` = phone is offline
* - `"m"` = mobile data
*/
conn?: string;
/** Device name */
device?: Device;
/** Timestamp in a readable format */
disptst: string;
/** Base64-encoded face image (device icon) */
face?: string;
/**
* Geohash of the location
* https://en.wikipedia.org/wiki/Geohash
*/
ghash?: string;
/** Regions the device is currently in (e.g. `["Home", "Garage"]`). Might be empty. */
inregions?: string[];
/**
* No idea; some kind of timestamp as well - figure it out yourself. :)
* https://github.com/owntracks/recorder/blob/df009f791a845012e9cce24923e6203a079ca1ed/storage.c#L659
* https://github.com/owntracks/recorder/blob/df009f791a845012e9cce24923e6203a079ca1ed/storage.c#L704
*/
isorcv?: string;
/** ISO 8601 timestamp */
isotst?: string;
/** Latitude in degrees */
lat: number;
/** Longitude in degrees */
lon: number;
/** Friendly device name */
name?: string;
/**
* Trigger for the location report
*
* - `"p"` = ping issued randomly by background task
* - `"c"` = circular region enter/leave event
* - `"b"` = beacon region enter/leave event (iOS only)
* - `"r"` = response to a reportLocation cmd message
* - `"u"` = manual publish requested by the user
* - `"t"` = timer based publish in move move (iOS only)
* - `"v"` = updated by Settings/Privacy/Locations Services/System Services/Frequent Locations monitoring (iOS only)
*/
t?: string;
/** Tracker ID used to display the initials of a user */
tid?: string;
/**
* Original publish topic
* https://owntracks.org/booklet/tech/json/#topics
*/
topic?: string;
/** UNIX epoch timestamp of the location fix in seconds */
tst: number;
/** User */
username?: User;
/** Vertical accuracy of the alt element in meters */
vac?: number;
/** Velocity in km/h */
vel?: number;
}
/** URL query parameters (prior to any parsing so it's all strings). */
interface QueryParams {
/** Map center latitude */
lat?: string;
/** Map center longitude */
lng?: string;
/** Start date and time of selected time range */
start?: string;
/** End date and time of selected time range */
end?: string;
/** Selected user */
user?: string;
/** Selected device */
device?: string;
/** Comma-separated list of active layers */
layers?: string;
}
/** Callback for new WebSocket location messages. */
interface WebSocketLocationCallback { (): void }
/** Function for lazy evaluation of log messages. */
interface LogMessageFunction { (): string }
/** A CSS color. */
type Color = string;
/** A CSS color that will use `primaryColor` as fallback. */
type OptionalColor = Color | null;
/** A user's name. */
type User = string;
/** A device's name. */
type Device = string;
/** Multiple location histories mapped to user and devices. */
type LocationHistory = { User: { Device: OTLocation[] } };

View File

@@ -10,6 +10,8 @@
"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",
"Download raw data": "Rohdaten herunterladen",
"Information": "Information",
"Show last known locations": "Zeige letzte bekannte Standorte",
@@ -19,13 +21,17 @@
"Minify JSON": "JSON minimieren",
"Copy to clipboard": "In die Zwischenablage kopieren",
"Download": "Herunterladen",
"Loading version...": "Version wird abgerufen...",
"OwnTracks website": "OwnTracks Webseite",
"OwnTracks documentation": "OwnTracks Dokumentation",
"OwnTracks on Twitter": "OwnTracks auf Twitter",
"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"
"Speed": "Geschwindigkeit",
"Regions:": "Regionen:"
}

37
src/locales/en-GB.json Normal file
View File

@@ -0,0 +1,37 @@
{
"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",
"Download raw data": "Download raw data",
"Information": "Information",
"Show last known locations": "Show last known locations",
"Show location history (line)": "Show location history (line)",
"Show location history (points)": "Show location history (points)",
"Show location heatmap": "Show location heatmap",
"Minify JSON": "Minify JSON",
"Copy to clipboard": "Copy to clipboard",
"Download": "Download",
"Loading version...": "Loading version...",
"OwnTracks website": "OwnTracks website",
"OwnTracks documentation": "OwnTracks documentation",
"OwnTracks on Twitter": "OwnTracks on Twitter",
"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:"
}

View File

@@ -10,6 +10,8 @@
"Select user": "Select user",
"Show all": "Show all",
"Select device": "Select device",
"Distance travelled": "Distance traveled",
"Elevation gain / loss": "Elevation gain / loss",
"Download raw data": "Download raw data",
"Information": "Information",
"Show last known locations": "Show last known locations",
@@ -19,13 +21,17 @@
"Minify JSON": "Minify JSON",
"Copy to clipboard": "Copy to clipboard",
"Download": "Download",
"Loading version...": "Loading version...",
"OwnTracks website": "OwnTracks website",
"OwnTracks documentation": "OwnTracks documentation",
"OwnTracks on Twitter": "OwnTracks on Twitter",
"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"
"Speed": "Speed",
"Regions:": "Regions:"
}

37
src/locales/es-ES.json Normal file
View File

@@ -0,0 +1,37 @@
{
"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",
"Download raw data": "Descargar datos en crudo",
"Information": "Información",
"Show last known locations": "Mostrar última ubicación conocida",
"Show location history (line)": "Mostrar historial (línea)",
"Show location history (points)": "Mostrar historial (puntos)",
"Show location heatmap": "Mostra mapa de calor",
"Minify JSON": "Reducir JSON",
"Copy to clipboard": "Copiar al portapapeles",
"Download": "Descarga",
"Loading version...": "Cargando versión...",
"OwnTracks website": "OwnTracks - Sitio web",
"OwnTracks documentation": "OwnTracks - documentación",
"OwnTracks on Twitter": "OwnTracks en Twitter",
"Loading data, please wait...": "Cargando datos, por favor, espera...",
"Cancel": "Cancelar",
"Image of {deviceName}": "Imágen de {deviceName}",
"Timestamp": "Fecha / Hora",
"Location": "Ubicación",
"Address": "Dirección",
"Battery": "Bateria",
"Speed": "Velocidad",
"Regions:": "Regiones:"
}

37
src/locales/fr-FR.json Normal file
View File

@@ -0,0 +1,37 @@
{
"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",
"Download raw data": "Télécharger les données brutes",
"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",
"Download": "Télécharger",
"Loading version...": "Chargement de la version...",
"OwnTracks website": "Site d'OwnTracks",
"OwnTracks documentation": "Documentation d'OwnTracks",
"OwnTracks on Twitter": "OwnTracks sur Twitter",
"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:"
}

View File

@@ -20,6 +20,16 @@ const logColors = {
[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);
@@ -35,5 +45,9 @@ export const log = (label, message, level = logLevels.INFO) => {
padding: 3px;
`;
const logFunc = logFunctions[level];
logFunc(`%c${label}`, css, message);
logFunc(
`%c${label}`,
css,
typeof message === "function" ? message() : message
);
};

View File

@@ -1,22 +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";
import VModal from "vue-js-modal";
import VOutsideEvents from "vue-outside-events";
Vue.use(VModal);
Vue.use(VOutsideEvents);
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),
render: (h) => h(App),
}).$mount("#app");

View File

@@ -1,12 +1,13 @@
import Vue from "vue";
import Router from "vue-router";
import Map from "./views/Map.vue";
import config from "@/config";
import Map from "@/views/Map.vue";
Vue.use(Router);
export default new Router({
mode: "history",
base: process.env.BASE_URL,
base: config.router.basePath,
routes: [
{
path: "/",

View File

@@ -1,11 +1,12 @@
import * as types from "@/store/mutation-types";
import * as api from "@/api";
import config from "@/config";
import { isIsoDateTime } from "@/util";
/** @typedef {import("./types").QueryParams} QueryParams */
/** @typedef {import("./types").User} User */
/** @typedef {import("./types").Device} Device */
import { log } from "@/logging";
import {
distanceBetweenCoordinates,
isIsoDateTime,
getLocationHistoryCount,
} from "@/util";
/**
* Populate the state from URL query parameters.
@@ -42,7 +43,7 @@ const populateStateFromQuery = ({ state, commit }, query) => {
}
if (query.layers) {
const activeLayers = query.layers.split(",");
Object.keys(state.map.layers).forEach(layer => {
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 });
@@ -115,12 +116,59 @@ const getLastLocations = async ({ commit, state }) => {
// 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")
(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.
*/
@@ -136,15 +184,29 @@ const getLocationHistory = async ({ commit, state }) => {
} else {
devices = state.devices;
}
commit(
types.SET_LOCATION_HISTORY,
await api.getLocationHistory(
commit(types.SET_REQUEST_ABORT_CONTROLLER, new AbortController());
let locationHistory;
try {
locationHistory = await api.getLocationHistory(
devices,
state.startDateTime,
state.endDateTime
)
);
commit(types.SET_IS_LOADING, false);
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);
}
};
/**

View File

@@ -3,24 +3,47 @@ import L from "leaflet";
import config from "@/config";
import { distanceBetweenCoordinates } from "@/util";
/** @typedef {import("./types").State} State */
/** @typedef {import("./types").MultiLocationHistory} MultiLocationHistory */
/** @typedef {import("./types").DatepickerConfig} DatepickerConfig */
/**
* 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
* @param {MultiLocationHistory} state.locationHistory Location history of selected users and devices
* @return {Array.<L.LatLng>} All coordinates
* @returns {L.LatLng[]} All coordinates
*/
const locationHistoryLatLngs = state => {
const filteredLocationHistoryLatLngs = (state) => {
const latLngs = [];
Object.keys(state.locationHistory).forEach(user => {
Object.keys(state.locationHistory[user]).forEach(device => {
state.locationHistory[user][device].forEach(coordinate => {
latLngs.push(L.latLng(coordinate.lat, coordinate.lon));
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));
});
});
});
@@ -33,19 +56,23 @@ const locationHistoryLatLngs = state => {
* coordinates does not exceed `config.map.maxPointDistance`.
*
* @param {State} state
* @param {MultiLocationHistory} state.locationHistory Location history of selected users and devices
* @return {Array.<Array.<L.LatLng>>} Groups of coherent coordinates
* @returns {L.LatLng[][]} Groups of coherent coordinates
*/
const locationHistoryLatLngGroups = state => {
const filteredLocationHistoryLatLngGroups = (state) => {
const groups = [];
Object.keys(state.locationHistory).forEach(user => {
Object.keys(state.locationHistory[user]).forEach(device => {
const locationHistory = filteredLocationHistory(state);
Object.keys(locationHistory).forEach((user) => {
Object.keys(locationHistory[user]).forEach((device) => {
let latLngs = [];
state.locationHistory[user][device].forEach(coordinate => {
const latLng = L.latLng(coordinate.lat, coordinate.lon);
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 (config.map.maxPointDistance !== null && latLngs.length > 0) {
if (
typeof config.map.maxPointDistance === "number" &&
config.map.maxPointDistance > 0 &&
latLngs.length > 0
) {
const lastLatLng = latLngs.slice(-1)[0];
if (
distanceBetweenCoordinates(lastLatLng, latLng) >
@@ -66,6 +93,7 @@ const locationHistoryLatLngGroups = state => {
};
export default {
locationHistoryLatLngs,
locationHistoryLatLngGroups,
filteredLocationHistory,
filteredLocationHistoryLatLngs,
filteredLocationHistoryLatLngGroups,
};

View File

@@ -23,10 +23,17 @@ export default new Vuex.Store({
startDateTime: config.startDateTime.toISOString().slice(0, 19),
endDateTime: config.endDateTime.toISOString().slice(0, 19),
map: {
center: config.map.center,
zoom: config.map.zoom,
center: {
lat: 0,
lng: 0,
},
zoom: 19,
layers: config.map.layers,
},
distanceTravelled: 0,
elevationGain: 0,
elevationLoss: 0,
requestAbortController: null,
},
getters,
mutations,

View File

@@ -11,3 +11,7 @@ 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";

View File

@@ -40,4 +40,16 @@ export default {
[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;
},
};

View File

@@ -9,11 +9,14 @@
--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 {
html,
body {
height: 100%;
}
@@ -83,7 +86,8 @@ pre {
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;
"Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier,
monospace;
overflow-x: auto;
code {
@@ -105,29 +109,106 @@ pre {
color: var(--color-primary-text);
background: var(--color-primary);
nav {
&.header-sm {
padding: 10px;
.header-item:not(.nav-sm) .nav-item:not(:first-child) {
margin-left: 10px;
}
}
.header-item {
display: flex;
flex: 1;
align-items: center;
&:not(:first-child) {
margin-left: 20px;
}
&.nav-shrink {
flex: 0 1 auto;
&-grow {
flex: 1;
}
.nav-item:not(:first-child) {
margin-left: 20px;
&-right {
margin-left: auto !important;
}
.feather {
font-size: 20px;
margin-right: 10px;
}
.button-icon .feather {
.nav-item {
&:not(:first-child) {
margin-left: 20px;
}
> span {
margin: 0 5px;
}
.feather {
margin-right: 10px;
}
.button-icon .feather {
margin: 0;
}
}
&.nav-sm {
background: var(--color-primary);
border-top: 1px solid rgba(0, 0, 0, 0.2);
bottom: 0;
display: block;
left: 0;
margin: 0;
overflow-x: auto;
padding: 20px;
position: absolute;
right: 0;
top: 56px;
z-index: 1;
.nav-item {
align-items: center;
display: flex;
flex-wrap: wrap;
margin-left: auto;
margin-right: auto;
max-width: 400px;
&:not(:first-child) {
margin-top: 20px;
}
> .button,
> .dropdown,
> .date-time-picker {
flex: 1;
}
> .dropdown .dropdown-button,
> .date-time-picker .dropdown-button {
width: 100%;
}
> .date-time-picker {
margin-left: 0;
margin-right: 0;
}
> span {
flex-basis: 100%;
margin: 0;
}
// THIS IS TERRIBLE (but it works for now)
> :not(:nth-child(1)):not(:nth-child(2)) {
display: block;
margin-left: 30px;
margin-top: 5px;
}
}
}
}
}
@@ -171,8 +252,8 @@ pre {
overflow: hidden;
padding: 8px 16px;
text-overflow: ellipsis;
white-space: nowrap;
transition: box-shadow 0.2s;
white-space: nowrap;
&:focus {
outline: none;

View File

@@ -4,7 +4,10 @@
.leaflet-popup {
filter: var(--drop-shadow);
margin-bottom: 25px;
&--for-pin {
margin-bottom: calc(var(--pin-width) * 1.5 + 20px);
}
.leaflet-popup-content-wrapper {
border-radius: 3px;
@@ -44,4 +47,38 @@
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);
}
}
}
}
}

View File

@@ -1,22 +1,20 @@
.v--modal-overlay {
background: rgba(0, 0, 0, 0.5) !important;
.v--modal-background-click {
display: flex;
justify-content: center;
align-items: center;
.v--modal-box.v--modal {
top: initial !important;
left: initial !important;
width: auto !important;
height: auto !important;
max-width: 95vw;
max-height: 95vh;
overflow: auto;
padding: 30px;
border-radius: 3px;
background: var(--color-background);
}
.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);
}
}

View File

@@ -1,55 +0,0 @@
/**
* A coordinate with latitude and longitude.
*
* @typedef Coordinate
* @type {(Object|L.LatLng)}
* @property {Number} lat Latitude
* @property {Number} lng Longitude
*/
/**
* Vuex state.
*
* @typedef {Object.<String, *>} State
*/
/**
* URL query parameter object.
*
* @typedef {Object.<String, *>} QueryParams
*/
/**
* @typedef {Object} DatepickerConfig
* @property {Function} DatepickerConfig.customPredictor Custom predictor function
*/
/**
* A user's name.
*
* @typedef {String} User
*/
/**
* A device's name.
*
* @typedef {String} Device
*/
/**
* A last location object.
*
* @typedef {Object.<String, *>} LastLocation
*/
/**
* An array of location history objects
*
* @typedef {Array.<Object.<String, *>>} LocationHistory
*/
/**
* Multiple arrays of location history objects mapped to user and devices.
*
* @typedef {Object.<User, Object.<Device, LocationHistory>>} MultiLocationHistory
*/

View File

@@ -3,18 +3,16 @@ import moment from "moment";
import config from "@/config";
import { DATE_TIME_FORMAT, EARTH_RADIUS_IN_KM } from "@/constants";
/** @typedef {import("./types").Coordinate} Coordinate */
/**
* Get a complete URL for any API resource, taking the
* base URL configuration into account.
*
* @param {String} path Path to the API resource
* @return {URL} Final API URL
* @returns {URL} Final API URL
*/
export const getApiUrl = path => {
export const getApiUrl = (path) => {
const normalizedBaseUrl = config.api.baseUrl.endsWith("/")
? config.api.baseUrl.slice(1)
? config.api.baseUrl.slice(0, -1)
: config.api.baseUrl;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return new URL(`${normalizedBaseUrl}${normalizedPath}`);
@@ -24,17 +22,17 @@ export const getApiUrl = path => {
* Check if the given string is an ISO 8601 YYYY-MM-DDTHH:MM:SS datetime.
*
* @param {String} s Input value to be tested
* @return {Boolean} Whether the input matches the expected format
* @returns {Boolean} Whether the input matches the expected format
*/
export const isIsoDateTime = s => moment(s, DATE_TIME_FORMAT, true).isValid();
export const isIsoDateTime = (s) => moment(s, DATE_TIME_FORMAT, true).isValid();
/**
* Convert degrees to radians.
*
* @param {Number} degrees Angle in degrees
* @return {Number} Angle in radians
* @returns {Number} Angle in radians
*/
export const degreesToRadians = degrees => (degrees * Math.PI) / 180;
export const degreesToRadians = (degrees) => (degrees * Math.PI) / 180;
/**
* Calculate the distance between two coordinates. Uses the haversine formula,
@@ -44,7 +42,7 @@ export const degreesToRadians = degrees => (degrees * Math.PI) / 180;
*
* @param {Coordinate} c1 First coordinate
* @param {Coordinate} c2 Second coordinate
* @return {Number} Distance in meters
* @returns {Number} Distance in meters
*/
export const distanceBetweenCoordinates = (c1, c2) => {
const r = EARTH_RADIUS_IN_KM * 1000;
@@ -83,3 +81,38 @@ export const download = (text, filename, mimeType = "text/plain") => {
element.click();
document.body.removeChild(element);
};
/**
* Format a distance in meters into a human-readable string with unit.
*
* This only supports m / km for now, but could read a config option and return
* ft / mi.
*
* @param {Number} distance Distance in meters
* @returns {String} Formatted string including unit
*/
export const humanReadableDistance = (distance) => {
let unit = "m";
if (Math.abs(distance) >= 1000) {
distance = distance / 1000;
unit = "km";
}
return `${distance.toLocaleString(config.locale, {
maximumFractionDigits: 1,
})} ${unit}`;
};
/**
* Get the total number of locations from a nested location history.
*
* @param {LocationHistory} locationHistory Location history
* @returns {Number} Total number of locations
*/
export const getLocationHistoryCount = (locationHistory) =>
Object.keys(locationHistory)
.map((user) =>
Object.keys(locationHistory[user])
.map((device) => locationHistory[user][device].length)
.reduce((a, b) => a + b, 0)
)
.reduce((a, b) => a + b, 0);

View File

@@ -37,6 +37,7 @@
v-for="l in lastLocations"
:key="`${l.topic}-marker`"
:lat-lng="[l.lat, l.lon]"
:icon="markerIcon"
>
<LDeviceLocationPopup
:user="l.username"
@@ -49,13 +50,15 @@
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
:regions="l.inregions"
:options="{ className: 'leaflet-popup--for-pin' }"
/>
</LMarker>
</template>
<template v-if="map.layers.line">
<LPolyline
v-for="(group, i) in locationHistoryLatLngGroups"
v-for="(group, i) in filteredLocationHistoryLatLngGroups"
:key="i"
:lat-lngs="group"
v-bind="polyline"
@@ -63,10 +66,14 @@
</template>
<template v-if="map.layers.points">
<template v-for="(userDevices, user) in locationHistory">
<template v-for="(userDevices, user) in filteredLocationHistory">
<template v-for="(deviceLocations, device) in userDevices">
<LCircleMarker
v-for="(l, n) in deviceLocations"
v-for="(l, n) in deviceLocationsWithNameAndFace(
user,
device,
deviceLocations
)"
:key="`${user}-${device}-${n}`"
:lat-lng="[l.lat, l.lon]"
v-bind="circleMarker"
@@ -74,12 +81,15 @@
<LDeviceLocationPopup
:user="user"
:device="device"
:name="l.name"
:face="l.face"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
:regions="l.inregions"
></LDeviceLocationPopup>
</LCircleMarker>
</template>
@@ -88,8 +98,8 @@
<template v-if="map.layers.heatmap">
<LHeatmap
v-if="locationHistoryLatLngs.length"
:lat-lng="locationHistoryLatLngs"
v-if="filteredLocationHistoryLatLngs.length"
:lat-lng="filteredLocationHistoryLatLngs"
:max="heatmap.max"
:radius="heatmap.radius"
:blur="heatmap.blur"
@@ -113,22 +123,11 @@ import {
LPolyline,
} from "vue2-leaflet";
import "leaflet/dist/leaflet.css";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
import markerShadow from "leaflet/dist/images/marker-shadow.png";
import * as types from "@/store/mutation-types";
import LCustomMarker from "@/components/LCustomMarker";
import LHeatmap from "@/components/LHeatmap";
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup";
// See https://github.com/KoRiGaN/Vue2Leaflet/issues/28#issuecomment-299038157
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
});
export default {
components: {
LMap,
@@ -148,6 +147,7 @@ export default {
center: this.$store.state.map.center,
controls: this.$config.map.controls,
heatmap: this.$config.map.heatmap,
markerIcon: LCustomMarker,
maxZoom: this.$config.map.maxZoom,
maxNativeZoom: this.$config.map.maxNativeZoom,
url: this.$config.map.url,
@@ -174,8 +174,12 @@ export default {
});
},
computed: {
...mapGetters(["locationHistoryLatLngs", "locationHistoryLatLngGroups"]),
...mapState(["lastLocations", "locationHistory", "map"]),
...mapGetters([
"filteredLocationHistory",
"filteredLocationHistoryLatLngs",
"filteredLocationHistoryLatLngGroups",
]),
...mapState(["lastLocations", "map"]),
},
methods: {
...mapMutations({
@@ -190,18 +194,51 @@ export default {
(this.map.layers.line ||
this.map.layers.points ||
this.map.layers.heatmap) &&
this.locationHistoryLatLngs.length > 0
this.filteredLocationHistoryLatLngs.length > 0
) {
this.$refs.map.mapObject.fitBounds(
new L.LatLngBounds(this.locationHistoryLatLngs)
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));
const locations = this.lastLocations.map((l) => L.latLng(l.lat, l.lon));
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(locations), {
maxZoom: this.maxNativeZoom,
});
}
},
/**
* Find a the last location object for a user/device combination from the
* local cache and backfill name and face attributes to each item from the
* passed array of location objects.
*
* @param {User} user Username
* @param {Device} device Device name
* @param {OTLocation[]} deviceLocations Device name
* @returns {OTLocation[]} Updated locations
*/
deviceLocationsWithNameAndFace(user, device, deviceLocations) {
const lastLocation = this.lastLocations.find(
(l) => l.username === user && l.device === device
);
if (!lastLocation) {
return deviceLocations;
}
return deviceLocations.map((l) => ({
...l,
name: lastLocation.name,
face: lastLocation.face,
}));
},
},
watch: {
lastLocations() {
if (this.$config.onLocationChange.fitView) {
this.fitView();
}
},
filteredLocationHistory() {
this.fitView();
},
},
};
</script>

View File

@@ -4,6 +4,7 @@ import {
isIsoDateTime,
degreesToRadians,
distanceBetweenCoordinates,
humanReadableDistance,
} from "@/util";
describe("getApiUrl", () => {
@@ -21,6 +22,13 @@ describe("getApiUrl", () => {
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"
);
});
});
@@ -102,3 +110,22 @@ describe("distanceBetweenCoordinates", () => {
).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");
});
});

View File

@@ -6,6 +6,7 @@ const packageJson = fs.readFileSync("./package.json");
const version = JSON.parse(packageJson).version;
module.exports = {
publicPath: "",
configureWebpack: {
plugins: [
new webpack.DefinePlugin({

7972
yarn.lock

File diff suppressed because it is too large Load Diff