180 Commits

Author SHA1 Message Date
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
988b10de40 Release 2.0.0-beta.7 2020-01-24 21:20:22 +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
639e96cae8 Upgrade dependencies 2020-01-24 20:43:21 +00:00
Linus Groh
df3dcb60d8 Update year in LICENSE 2020-01-05 11:52:40 +00:00
Linus Groh
09ce3b7861 Lint code on Travis CI 2019-12-16 21:20:31 +00:00
Linus Groh
04f0b65480 Separate linting and formatting 2019-12-16 21:16:33 +00:00
Linus Groh
d3e3b82a13 Lint/format markdown files 2019-12-16 21:06:01 +00:00
Linus Groh
769185ee5a Set no-console/no-debugger to "warn" in dev mode 2019-12-16 20:24:15 +00:00
Linus Groh
7b6641e70d Add jsconfig.json 2019-12-16 19:35:17 +00:00
Linus Groh
871f9f0cb2 Add index.d.ts 2019-12-15 20:59:57 +00:00
Linus Groh
2827d85865 Release 2.0.0-beta.6 2019-12-14 19:39:56 +00:00
Linus Groh
693947c064 Fix heatmap 2019-12-14 19:35:24 +00:00
Linus Groh
a31c048060 Release 2.0.0-beta.5 2019-12-14 17:13:13 +00:00
Linus Groh
1f2be0aeb9 Upgrade dependencies 2019-12-14 17:10:22 +00:00
Linus Groh
92401eb6b1 Add $config Vue instance property 2019-12-14 16:59:12 +00:00
Linus Groh
8d2f22d3de Use configured locale for timestamp formatting 2019-12-14 16:48:18 +00:00
Linus Groh
1d106e45da Add Leaflet popup close button bg color transition 2019-12-14 16:33:17 +00:00
Linus Groh
1f07ae9266 Improve accessibility
* More title attributes
* Usage of aria-hidden and role attributes
* Focus style improvements
* Text contrast improvements
2019-12-14 16:32:10 +00:00
Linus Groh
8399476195 Release 2.0.0-beta.4 2019-12-14 09:43:07 -05:00
Linus Groh
7767a06875 Add support for time selection 2019-12-14 09:35:46 -05:00
Linus Groh
5bcb7a63bc Add missing translation of "to" 2019-12-14 13:37:56 +00:00
Linus Groh
f0b3ed2632 Add example commit for new locale 2019-12-13 20:33:54 +00:00
Linus Groh
185d6fd842 Clarify i18n development notes 2019-12-13 20:29:10 +00:00
Linus Groh
fac0479b25 Release 2.0.0-beta.3 2019-12-13 19:53:31 +00:00
Linus Groh
b2edda410f Add German translations 2019-12-13 19:45:53 +00:00
Linus Groh
73465268e2 Add i18 support 2019-12-13 19:45:21 +00:00
Linus Groh
4e449235b2 Add custom checkbox focus style 2019-12-13 18:41:27 +00:00
Linus Groh
1a7f969b59 Fix checkbox style issues 2019-12-13 18:41:02 +00:00
Linus Groh
e7e6ea7dda Fix hover/focus inconsistencies 2019-12-13 18:40:14 +00:00
Linus Groh
9f522dd727 Reimplement layer settings dropdown 2019-12-13 18:39:33 +00:00
Linus Groh
76e8a56cc7 Mention LISTEN_PORT environment variable in docs 2019-12-13 18:37:33 +00:00
Linus Groh
012eb74837 Update README.md 2019-12-11 22:25:59 +00:00
Linus Groh
207a63c0d8 Upgrade dependencies 2019-12-11 22:12:45 +00:00
Linus Groh
bbc381e70c Update README.md 2019-11-07 19:23:53 +00:00
Linus Groh
de45906860 Add drop shadow to window screenshot 2019-11-06 22:36:59 +00:00
Linus Groh
1734ef7c74 Update screenshots 2019-11-06 22:21:32 +00:00
Linus Groh
c4d368eee9 Fix Docker image labels
The LABEL instructions in docker/Dockerfile were not applied to the
final image, it only had the ones inherited from the nginx image.
2019-11-02 19:18:56 +00:00
Linus Groh
f0ff18c792 Add Travis CI build status badge 2019-11-02 18:23:49 +00:00
Linus Groh
220bda6ef3 Release 2.0.0-beta.2 2019-11-02 18:17:48 +00:00
Linus Groh
6209c806a2 Upgrade dependencies 2019-11-02 17:30:13 +00:00
Linus Groh
c85e6fedf2 Fix timezone issues in tests 2019-11-02 17:25:17 +00:00
Linus Groh
edff370dc8 Enable Travis CI 2019-11-02 16:54:59 +00:00
Linus Groh
69edbc6ce4 Fix typo 2019-11-02 16:34:11 +00:00
Linus Groh
76f1d4980c Add onLocationChange.reloadHistory config option 2019-11-01 22:37:51 +00:00
Linus Groh
39fd7727f4 Upgrade dependencies 2019-10-27 12:34:01 +00:00
Linus Groh
418a2fe808 Fix ESLint errors 2019-10-27 12:28:29 +00:00
Linus Groh
f0c4ba43cb Fix config TOC links 2019-10-26 22:52:42 +01:00
Linus Groh
5d6208d57a Release 2.0.0-beta.1 2019-10-26 22:39:18 +01:00
Linus Groh
bd25881199 Fix branch name for version badge in README.md 2019-10-26 21:51:15 +01:00
Linus Groh
d029fb5360 Merge pull request #16 from owntracks/v2.0.0-alpha
v2.0.0 alpha
2019-10-26 21:48:31 +01:00
Linus Groh
de3d83e28f Add volume for config.js to docker-compose example 2019-10-26 19:33:09 +01:00
Linus Groh
9bd7fb8681 Upgrade dependencies 2019-10-26 19:20:06 +01:00
Linus Groh
9dbf6e78f1 Fix build by upgrading eslint-loader 2019-10-26 19:13:50 +01:00
Linus Groh
d5e21a2ada Upgrade Vue CLI packages 2019-10-26 19:08:50 +01:00
Linus Groh
942df6d001 Merge branch 'master' into v2.0.0-alpha 2019-10-26 18:34:10 +01:00
Linus Groh
5ffe6025ae Rename config.default.js to config.example.js 2019-10-26 18:11:54 +01:00
Linus Groh
037b140311 Add newline to end of .dockerignore 2019-10-26 18:04:57 +01:00
Linus Groh
5a24cac5a1 Add .dockerignore 2019-10-26 17:57:39 +01:00
Linus Groh
3444b75345 Update Dockerfile for v2 2019-10-26 17:57:33 +01:00
Linus Groh
1c05bb17b4 Fix button text overflow 2019-10-22 22:13:12 +01:00
Linus Groh
f14f97b416 Add verbose mode 2019-10-22 22:12:59 +01:00
Linus Groh
50a513d144 Add custom checkbox styles 2019-10-22 20:19:13 +01:00
Linus Groh
31101a9818 Update cors-proxy instructions to use .env file 2019-10-22 19:55:25 +01:00
Linus Groh
28803bfd2d Add version and license badges 2019-10-21 13:59:27 +02:00
Linus Groh
da7a0aa5d6 Update distanceBetweenCoordinates 2019-10-02 19:23:45 +01:00
Linus Groh
12910fe66d Fix comment in test 2019-10-02 19:06:31 +01:00
Linus Groh
193882c4e7 Remove console.log 2019-10-02 19:04:27 +01:00
Linus Groh
968355cfb7 Use v-bind for LCircle 2019-10-02 19:04:09 +01:00
Linus Groh
c6181c77b1 Update heatmap when latLng changes 2019-10-02 19:03:36 +01:00
Linus Groh
9e61b7f174 Fix icon alignment 2019-10-02 19:03:12 +01:00
Linus Groh
432ec4bac4 Add loading indicator 2019-10-02 19:02:29 +01:00
Linus Groh
64a820a218 Add gradient config support to heatmap 2019-10-01 22:03:30 +01:00
Linus Groh
96a0daa05e Fix heatmap 2019-10-01 21:56:37 +01:00
Linus Groh
3571ac2724 Add "Loading version..." label to info modal 2019-10-01 21:21:41 +01:00
Linus Groh
5ff89c5484 Add OwnTracks website to info modal 2019-10-01 21:18:34 +01:00
Linus Groh
4a64b939be Upgrade outdated dependencies 2019-10-01 21:01:01 +01:00
Linus Groh
efbf980924 Implement data copying & download 2019-10-01 20:56:51 +01:00
Linus Groh
6c6763ebfc Refactor modals 2019-10-01 19:23:15 +01:00
Linus Groh
874847d22f Don't cut of button / date select labels 2019-10-01 19:02:39 +01:00
Linus Groh
937bafc3f0 Add tests for API functions 2019-09-30 19:10:11 +01:00
Linus Groh
506f12b66e Update JSDoc type definitions 2019-09-29 08:10:28 +01:00
Linus Groh
98cb52b31b Add JSDoc to more functions 2019-09-29 08:01:45 +01:00
Linus Groh
03ecce52af Fix typo 2019-09-29 08:00:29 +01:00
Linus Groh
be63a12607 Clean up and document configuration options 2019-09-29 08:00:16 +01:00
Linus Groh
69619665f4 Add linebreaks between lat lon alt 2019-09-28 22:32:57 +01:00
Linus Groh
b78d915751 Update location popup 2019-09-28 22:05:01 +01:00
Linus Groh
a12290d343 Enhance outline & flat button accessibility 2019-09-28 19:25:31 +01:00
Linus Groh
cf993d11dd Update information modal 2019-09-28 19:16:29 +01:00
Linus Groh
a443393bba Increase base font size to 14px 2019-09-28 19:06:24 +01:00
Linus Groh
4d06e1c07e Use vue-js-modal for modals 2019-09-28 19:01:57 +01:00
Linus Groh
31a85e42a6 Refactor styles 2019-09-28 14:50:05 +01:00
Linus Groh
81d9b63dd4 Reset selected device when changing user 2019-09-28 14:06:37 +01:00
Linus Groh
2d9ad44a23 Ignore selectedDevice if selectedUser is null 2019-09-28 14:06:12 +01:00
Linus Groh
4fc79adf81 Update default config comments 2019-09-28 12:33:21 +01:00
Linus Groh
6d4ff0d96b Rename accentColor to primaryColor 2019-09-28 12:32:59 +01:00
Linus Groh
d01de41e1f Return URL instance from getApiUrl 2019-09-27 22:12:49 +01:00
Linus Groh
600934183a Add tests 2019-09-27 21:13:40 +01:00
Linus Groh
a2de94fd44 Fix getApiUrl implementation 2019-09-27 20:52:16 +01:00
Linus Groh
4eb89abf3d Remove unused util function 2019-09-27 20:25:54 +01:00
Linus Groh
87647c81d3 Add config option to ignore ping/ping 2019-09-27 19:46:34 +01:00
Linus Groh
03712ef2a1 Add reloadData action 2019-09-27 19:33:55 +01:00
Linus Groh
c64ddd9e18 Fix typo 2019-09-27 19:26:27 +01:00
Linus Groh
75c3462eae Clarify cors-proxy usage 2019-09-27 19:19:38 +01:00
Linus Groh
9f35dbd5f2 Update README.md 2019-09-27 19:16:00 +01:00
Linus Groh
4d971d57f7 Publish 2.0.0-alpha source 2019-09-27 18:34:41 +01:00
78 changed files with 15670 additions and 1057 deletions

3
.browserslistrc Normal file
View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie > 0

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
node_modules
docs
scripts
tests
LICENSE
README.md
*Dockerfile*
*docker-compose*

36
.eslintrc.js Normal file
View File

@@ -0,0 +1,36 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: ["plugin:vue/essential", "@vue/prettier"],
rules: {
"no-console": process.env.NODE_ENV === "production" ? "error" : "warn",
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
"max-len": [
"error",
{
ignoreUrls: true,
},
],
"prettier/prettier": [
"error",
{
trailingComma: "es5",
printWidth: 80,
htmlWhitespaceSensitivity: "ignore",
},
],
},
parserOptions: {
parser: "babel-eslint",
},
overrides: [
{
files: ["**/__tests__/*.{j,t}s?(x)"],
env: {
jest: true,
},
},
],
};

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

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

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

@@ -0,0 +1,17 @@
name: Build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install dependencies
uses: borales/actions-yarn@v2.0.0
with:
cmd: install
- name: Run production build
uses: borales/actions-yarn@v2.0.0
with:
cmd: build

75
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Build Docker images
on:
schedule:
- cron: '0 3 * * *' # everyday at 3am
pull_request:
branches: master
push:
branches: master
tags:
- v*
release:
types: [published]
branches: master
tags:
- v*
jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Prepare
id: prepare
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: |
DOCKER_IMAGE=owntracks/frontend
DOCKER_PLATFORMS=linux/amd64,linux/arm/v7,linux/arm64
VERSION=master
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
fi
if [ "${{ github.event_name }}" = "schedule" ]; then
VERSION=nightly
fi
TAGS="--tag ${DOCKER_IMAGE}:${VERSION}"
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
TAGS="$TAGS --tag ${DOCKER_IMAGE}:latest"
fi
echo ::set-output name=docker_image::${DOCKER_IMAGE}
echo ::set-output name=version::${VERSION}
echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} \
--build-arg VERSION=${VERSION} \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg VCS_REF=${GITHUB_SHA::8} \
${TAGS} --file ./docker/Dockerfile .
- name: Set up Docker Buildx
uses: crazy-max/ghaction-docker-buildx@v3
- name: Docker Buildx (build)
run: |
docker buildx build --output "type=image,push=false" ${{ steps.prepare.outputs.buildx_args }}
- name: Docker Login
if: success() && github.event_name != 'pull_request'
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
echo "${DOCKER_PASSWORD}" | docker login --username "${DOCKER_USERNAME}" --password-stdin
- name: Docker Buildx (push)
if: success() && github.event_name != 'pull_request'
run: |
docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }}
- name: Docker Check Manifest
if: always() && github.event_name != 'pull_request'
run: |
docker run --rm mplatform/mquery ${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.version }}
- name: Clear
if: always() && github.event_name != 'pull_request'
run: |
rm -f ${HOME}/.docker/config.json

21
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Lint
on: [push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install dependencies
uses: borales/actions-yarn@v2.0.0
with:
cmd: install
- name: Lint JavaScript/Vue files
uses: borales/actions-yarn@v2.0.0
with:
cmd: lint:js
- name: Lint Markdown files
uses: borales/actions-yarn@v2.0.0
with:
cmd: lint:md

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

@@ -0,0 +1,17 @@
name: Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install dependencies
uses: borales/actions-yarn@v2.0.0
with:
cmd: install
- name: Run unit tests
uses: borales/actions-yarn@v2.0.0
with:
cmd: test

23
.gitignore vendored
View File

@@ -1,3 +1,26 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
public/config/config.js
# Logs
logs
*.log

View File

@@ -1,9 +1,164 @@
# 1.1.0 (2019-10-26)
# Changelog
- Add support for Docker. [#7](https://github.com/owntracks/frontend/pull/7), [@sharkoz](https://github.com/sharkoz)
- Move project to the OwnTracks organisation on GitHub. [#8](https://github.com/owntracks/frontend/pull/8), [@jpmens](https://github.com/jpmens)
- Enable compression in nginx configuration used in Docker image. [#11](https://github.com/owntracks/frontend/pull/11), [@sharkoz](https://github.com/sharkoz)
Dates are in UTC.
# 1.0.0 (2019-06-18)
## 2.5.0 (2020-09-07)
- Add `filters.fitView` config option - this will prevent the map from re-fitting automatically by default when a live location changes ([#41](https://github.com/owntracks/frontend/issues/41))
- Show regions for location on popup
- Fix vertical offset of non-pin popups
- Build Docker images for multiple architectures (linux/amd64, linux/arm/v7, linux/arm64) using GitHub Actions ([#38](https://github.com/owntracks/frontend/pull/38), [@wollew](https://github.com/wollew))
- Replace Travis CI with GitHub Actions build/lint/test workflows ([#39](https://github.com/owntracks/frontend/pull/39))
- Replace node-sass with sass (dart-sass)
- Upgrade dependencies
## 2.4.0 (2020-06-01)
- Add `filters.minAccuracy` config option - this allows ignoring location points which do
not meet the configured accuracy requirement ([#35](https://github.com/owntracks/frontend/issues/35))
- Upgrade dependencies
## 2.3.1 (2020-05-09)
- Fix linting issue in `config.md`
## 2.3.0 (2020-05-09)
- Add `api.fetchOptions` config option - this allows sending custom HTTP headers or including
cookies in the request
- Upgrade dependencies
## 2.2.0 (2020-03-18)
- Improve mobile layout further:
- Reduce header paddings
- Align buttons/dropdowns
- Upgrade dependencies
## 2.1.0 (2020-03-18)
- Replace default Leaflet marker with a custom one ([#2](https://github.com/owntracks/frontend/issues/2))
- Improve verbose mode logging
- Improve mobile usability ([#19](https://github.com/owntracks/frontend/issues/19))
- Upgrade dependencies
## 2.0.0 (2020-03-01)
Stable release of v2, finally! 🎉
_This is just a version bump, see all the beta releases below, especially the first one, for a list of changes._
## 2.0.0-beta.11 (2020-03-01)
- Add Spanish translations ([#25](https://github.com/owntracks/frontend/pull/25), [@dtorner](https://github.com/dtorner))
- Change "distance travelled" label to `title`
- Replace map initial center/zoom config with auto fitting ([#23](https://github.com/owntracks/frontend/issues/23))
- Enhance code type definitions using TypeScript features ([#20](https://github.com/owntracks/frontend/pull/20))
- Upgrade dependencies
## 2.0.0-beta.10 (2020-02-07)
- Add "distance travelled" feature
## 2.0.0-beta.9 (2020-02-06)
- Support locale with language and region part (`en-GB`)
- Update docs (screenshot, changelog improvements, typo fix)
- Add funding information
## 2.0.0-beta.8 (2020-01-26)
- Add friendly device name and face images to location history popups
- Add missing `alt`/`title` to device face image
- Fix all JSDoc `@return` directives to `@returns`
- Use computed prop for device name in location popup
- Enable ESLint `max-len` rule
## 2.0.0-beta.7 (2020-01-24)
This release doesn't really affect end-users but greatly improves the development experience.
- Add `jsconfig.json`
- Set `no-console`/`no-debugger` to `"warn"` in dev mode
- Linting and formatting:
- Separate npm scripts for linting and formatting
- Lint/format Markdown files
- Run lint on Travis CI
- Upgrade dependencies
## 2.0.0-beta.6 (2019-12-14)
- Fix heatmap - the upgrade of `vue2-leaflet` from 2.2.1 to 2.3.0 added an `activated` attribute to layers causing the heatmap to not show ([#18](https://github.com/owntracks/frontend/issues/18))
## 2.0.0-beta.5 (2019-12-14)
- Add Leaflet popup close button background color transition
- Add `$config` Vue instance property
- Improve accessibility ([#9](https://github.com/owntracks/frontend/issues/9))
- Use configured locale for timestamp formatting
- Upgrade dependencies
## 2.0.0-beta.4 (2019-12-14)
- Add support for time selection ([#10](https://github.com/owntracks/frontend/issues/10))
- New date/time picker component is properly translated/localised and keyboard accessible
- Config options are now `startDateTime`/`endDateTime` and format of URL parameters changed
- Changed default start/end date and time to use local timezone
- Fix missing translation of "[date] to [date]"
- Update i18n development notes in `README.md`
## 2.0.0-beta.3 (2019-12-13)
- Add i18 support (currently English and German, `locale` config option)
- Add custom checkbox focus style
- Fix layer dropdown issues ([#1](https://github.com/owntracks/frontend/issues/1))
- Fix checkbox style issues
- Fix hover/focus inconsistencies
- Fix Docker image labels
- `README.md` enhancements
- Upgrade dependencies
## 2.0.0-beta.2 (2019-11-02)
- Add `onLocationChange.reloadHistory` config option
- Add Travis CI config
- Fix timezone issues in tests
- Fix ESLint errors in production mode
- Fix table of content links in config documentation
- Upgrade dependencies
## 2.0.0-beta.1 (2019-10-26)
- Convert codebase to Node.js based development workflow, including:
- Package management using yarn
- Build step using Webpack and Babel
- Usage of Vue single file components
- SCSS and PostCSS
- ESLint configuration for linting and consistent code style
- `package.json` scripts: `serve`, `build`, `lint`, `cors-proxy` and `test`
- Design updates, including:
- New default primary color (same as OwnTracks Android app)
- Improved hover and focus styles as a first attempt to improve accessibility
- Improved modals and location popups
- Custom checkbox styles
- Switch from Font Awesome 4 to Feather Icons
- Application now uses Vuex and Vue Router
- Add URL query parameters to load and preserve application state: `lat`, `lng`, `zoom`, `start`, `end`, `user`, `device` and `layers`
- Add a loading indicator
- Add 'download data' modal, currently supporting formatted and minified JSON
- Add a verbose mode
- Add CORS proxy script to easily use a production instance of the OwnTracks recorder in development
- Add unit tests for util and API functions
- Add documentation for all public funtions
- Add documentation for all configuration options
- Add more configuration options, including setting the API base URL ([#4](https://github.com/owntracks/frontend/issues/4)) and hiding the `ping/ping` location ([#12](https://github.com/owntracks/frontend/issues/12))
## 1.1.0 (2019-10-26)
- Add support for Docker ([#7](https://github.com/owntracks/frontend/pull/7), [@sharkoz](https://github.com/sharkoz))
- Move project to the OwnTracks organisation on GitHub ([#8](https://github.com/owntracks/frontend/pull/8), [@jpmens](https://github.com/jpmens))
- Enable compression in nginx configuration used in Docker image ([#11](https://github.com/owntracks/frontend/pull/11), [@sharkoz](https://github.com/sharkoz))
## 1.0.0 (2019-06-18)
- Initial release

View File

@@ -1,11 +0,0 @@
FROM nginx:alpine
EXPOSE 80
ENV LISTEN_PORT=80 \
SERVER_HOST=otrecorder \
SERVER_PORT=80
COPY nginx.tmpl /etc/nginx/nginx.tmpl
COPY index.html /usr/share/nginx/html
COPY static/ /usr/share/nginx/html/static/
CMD /bin/sh -c "envsubst '\${SERVER_HOST} \${SERVER_PORT} \${LISTEN_PORT}' < /etc/nginx/nginx.tmpl > /etc/nginx/nginx.conf && nginx -g 'daemon off;' || ( env; cat /etc/nginx/nginx.conf )"

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019 Linus Groh
Copyright (c) 2019-2020 Linus Groh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

157
README.md
View File

@@ -1,95 +1,154 @@
# OwnTracks UI
> A modern web interface for OwnTracks made with Vue.js
![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](https://github.com/owntracks/frontend/workflows/Build/badge.svg)](https://github.com/owntracks/frontend/actions?query=workflow%3ABuild+branch%3Amaster)
[![Tests](https://github.com/owntracks/frontend/workflows/Tests/badge.svg)](https://github.com/owntracks/frontend/actions?query=workflow%3ATests+branch%3Amaster)
[![Lint](https://github.com/owntracks/frontend/workflows/Lint/badge.svg)](https://github.com/owntracks/frontend/actions?query=workflow%3ALint+branch%3Amaster)
[![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/master/LICENSE)
<p style="text-align: center;">
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/owntracks-ui.png" alt="OwnTracks UI">
</p>
![OwnTracks UI](https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/owntracks-ui.png)
## Introduction
This is a web interface for [OwnTracks](https://github.com/owntracks/recorder), intended to replace the various web pages shipping with the recorder. OwnTracks UI uses Vue.js under the hood.
This is a web interface for [OwnTracks](https://github.com/owntracks/recorder) built as
a Vue.js single page application. The recorder itself already ships with some basic web
pages, this is a more advanced interface with more functionality, all in one place.
![Map features](https://raw.githubusercontent.com/owntracks/frontend/master/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,
speed and regions
- Location history (data points, line or both)
- Location heatmap
- Quickly fit all shown objects on the map into view
- Display data in a specific date and time range
- Filter by user or specific device
- Calculation of distance travelled
- Download selected location data as JSON
- Highly customisable
## Installation
### Manual install
Clone the repository and copy `index.html` and the `static/` directory to your server's webroot. The API is expected to be reachable under the same domain as the web interface.
### Docker
You can launch directly via Docker run like this:
A pre-built Docker image is available on Docker Hub as [`owntracks/frontend`](https://hub.docker.com/r/owntracks/frontend).
You can start a container directly via `docker run`:
```console
$ docker run -d -p 80:80 -e SERVER_HOST=otrecorder-host -e SERVER_PORT=otrecorder-port owntracks/frontend
$ docker run -d -p 80:80 -e SERVER_HOST=otrecorder-host -e SERVER_PORT=8083 owntracks/frontend
```
Or you can use `docker-compose` (if you also run the OwnTracks Recorder with the default compose config, and the service is named `otrecorder`):
Or you can use `docker-compose` (if you also run the OwnTracks Recorder with the default
compose config, and the service is named `otrecorder`):
```yaml
version: '3'
version: "3"
services:
owntracks-ui:
image: owntracks/frontend
ports:
- 80:80
volumes:
- ./path/to/custom/config.js:/usr/share/nginx/html/config/config.js
environment:
- SERVER_HOST=otrecorder
- SERVER_PORT=8083
restart: unless-stopped
```
## Features
To change the port on which the nginx server will listen on, set the
`LISTEN_PORT` enviroment variable - default is 80.
- Enable or disable multiple layers:
To build the image from source replace `image:` with:
- Last known (i.e. live) locations:
```yaml
build:
context: ./owntracks-frontend
dockerfile: docker/Dockerfile
```
- Accuracy visualization (circle)
- Device friendly name and icon
- Detailed information (if available): time, lat, lon, height, battery and speed
(assuming you have this repository cloned to `owntracks-frontend` in the same
directory as `docker-compose.yml`)
- Location history (data points, line or both)
- Location heatmap
- Button to quickly fit all shown objects on the map into view
### Manually
- Display data in a specific date range
- Filter by user and device
- Customizable:
- Run `yarn install` to install dependencies
- Run `yarn build` to compile and minify for production
- Copy the content of the `dist/` directory to your webroot
- UI color
- Default start and end date
- Map:
## Configuration
- Tile server
- Max zoom
- Default position and zoom
- Heatmap colors, radius and blur
It's possible to get started without any configuration change whatsoever, assuming your
OwnTracks API is reachable at the root of the same host as the frontend.
## Screenshots
Copy [`public/config/config.example.js`](public/config/config.example.js) to
`public/config/config.js` and make changes as you wish.
_Click to enlarge._
See [`docs/config.md`](docs/config.md) for all available options.
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/live.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/live.png" alt="Live" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/multiple.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/multiple.png" alt="Multiple" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/date-selection.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/date-selection.png" alt="Date selection" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" alt="Heatmap" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" alt="Customized" height="200"></a>
## Development
## ToDo
- Run `yarn install` to install dependencies
- Run `yarn serve` to compile for development and start the hot-reload server
- Run `yarn lint:js` to lint JavaScript/Vue files
- Run `yarn lint:md` to lint Markdown files
- Run `yarn format:js` to format JavaScript/Vue files
- Run `yarn format:md` to format Markdown files
- Run `yarn test` to run unit tests
- Node.js based development workflow:
### CORS-Proxy
- Webpack
- Vue SFCs
- Sass
- Dependency management with yarn instead of a local copy or unpkg.com
You can use the [`corsProxy.js`](scripts/corsProxy.js) script to use your production
instance of OwnTracks for development without making changes to its CORS-Headers:
- Add documentation, at least for the config file
- Download data for selected date range, user and device as JSON
```console
$ yarn cors-proxy
```
If you have [basic authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme)
enabled, create a `.env` file with your credentials:
```text
OT_BASIC_AUTH_USERNAME=user
OT_BASIC_AUTH_PASSWORD='P@$$w0rd'
```
Then run:
```console
$ env $(cat .env | xargs) yarn cors-proxy
```
The default host and port it binds to is `0.0.0.0:8888`. Change using the `OT_PROXY_HOST`
and `OT_PROXY_PORT` environment variables.
Finally update `api.baseUrl` in your config to `"http://0.0.0.0:8888/https://owntracks.example.com"`.
### I18n
This project uses [Vue I18n](https://kazupon.github.io/vue-i18n/). To see missing and
unused i18n entries, run:
```console
$ yarn i18n:report
```
To add a new locale, copy `en.json` to `<locale>.json` in [`src/locales`](src/locales)
and start translating the individual strings. Make sure to [mention the new locale to the docs](docs/config.md#locale)!
For a specific example see commit [`b2edda4`](https://github.com/owntracks/frontend/commit/b2edda410f16633aa6fd9cd4e5250f2031536c7d)
where German translations were added.
## Contributing
Please feel free to open an issue and discuss your ideas and report bugs. If you think you can help out with something, open a PR!
Please feel free to open an issue and discuss your ideas and report bugs. If you think
you can help out with something, open a PR!

3
babel.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

24
docker/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:10 as build
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install
COPY . ./
RUN yarn build
FROM nginx:1.17-alpine
LABEL version="2.5.0"
LABEL description="OwnTracks UI"
LABEL maintainer="Linus Groh <mail@linusgroh.de>"
ENV LISTEN_PORT=80 \
SERVER_HOST=otrecorder \
SERVER_PORT=80
COPY ./docker/nginx.tmpl /etc/nginx/nginx.tmpl
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
EXPOSE 80
CMD /bin/sh -c " \
envsubst '\${SERVER_HOST} \${SERVER_PORT} \${LISTEN_PORT}' \
< /etc/nginx/nginx.tmpl \
> /etc/nginx/nginx.conf \
&& nginx -g 'daemon off;' \
|| ( env; cat /etc/nginx/nginx.conf ) \
"

514
docs/config.md Normal file
View File

@@ -0,0 +1,514 @@
# Configuration
## Overview
All _custom_ configuation is stored in `window.owntracks.config`,
which is a regular JavaScript object - so you can use template strings, spread syntax,
comments and other JS features.
Some of the application state is synced to the URL's query parameters. If a parameter
exists in the URL query, it takes precedence over the configured value - otherwise the
configured value will be used and appended to the URL query.
Start with this:
```js
window.owntracks = window.owntracks || {};
window.owntracks.config = {};
```
**WARNING: if your configuration contains private data (most commonly your tile server**
**access key), make sure to protect access to it properly, e.g. with basic authentication.**
## Options
- `api`
- [`baseUrl`](#apibaseurl)
- [`fetchOptions`](#apifetchoptions)
- [`endDateTime`](#enddatetime)
- `filters`
- [`minAccuracy`](#filtersminaccuracy)
- [`ignorePingLocation`](#ignorepinglocation)
- [`locale`](#locale)
- `map`
- [`attribution`](#mapattribution)
- [`circle`](#mapcircle)
- [`circleMarker`](#mapcirclemarker)
- `controls`
- `scale`
- [`display`](#mapcontrolsscaledisplay)
- [`imperial`](#mapcontrolsscaleimperial)
- [`maxWidth`](#mapcontrolsscalemaxwidth)
- [`metric`](#mapcontrolsscalemetric)
- [`position`](#mapcontrolsscaleposition)
- `zoom`
- [`display`](#mapcontrolszoomdisplay)
- [`position`](#mapcontrolszoomposition)
- `heatmap`
- [`blur`](#mapheatmapblur)
- [`gradient`](#mapheatmapgradient)
- [`max`](#mapheatmapmax)
- [`radius`](#mapheatmapradius)
- `layers`
- [`heatmap`](#maplayersheatmap)
- [`last`](#maplayerslast)
- [`line`](#maplayersline)
- [`points`](#maplayerspoints)
- [`maxNativeZoom`](#mapmaxnativezoom)
- [`maxPointDistance`](#mapmaxpointdistance)
- [`maxZoom`](#mapmaxzoom)
- [`polyline`](#mappolyline)
- [`url`](#mapurl)
- `onLocationChange`
- [`fitView`](#onlocationchangefitview)
- [`reloadHistory`](#onlocationchangereloadhistory)
- [`primaryColor`](#primarycolor)
- [`selectedDevice`](#selecteddevice)
- [`selectedUser`](#selecteduser)
- [`showDistanceTravelled`](#showdistancetravelled)
- [`startDateTime`](#startdatetime)
- [`verbose`](#verbose)
### `api.baseUrl`
Base URL for the recorder's HTTP and WebSocket API. Keep CORS in mind.
- Type: [`String`]
- Default: current protocol and host
- Examples:
```js
// API requests will be made to https://owntracks.example.com/api/0/...
window.owntracks.config = {
api: {
baseUrl: "https://owntracks.example.com",
},
};
```
```js
// API requests will be made to https://example.com/owntracks/api/0/...
window.owntracks.config = {
api: {
baseUrl: "https://example.com/owntracks/",
},
};
```
### `api.fetchOptions`
Options for API requests (made with `fetch()`). See [`fetch()` docs on MDN] for details.
You can use this for example to send custom HTTP headers or to include cookies in the request.
- Type: [`Object`]
- Default: `{}`
- Example:
```js
// Include credentials (e.g. cookies)
window.owntracks.config = {
api: {
fetchOptions: {
credentials: "include",
},
},
};
```
### `endDateTime`
Initial end date and time (browser timezone) for fetched data.
- Type: [`Date`]
- Default: today, 23:59:59
- Example:
```js
// Data will be fetched up to 1970-01-01
window.owntracks.config = {
endDateTime: new Date(1970, 1, 1),
};
```
### `filters.minAccuracy`
Minimum accuracy in meters for location points to be rendered & included in the travelled distance.
This filter is disabled by default as accuracies can vary across devices an locations, but you're
encouraged to set it as it can be a simple way to remove outliers and vastly improve the travelled
distance calculation.
- Type: [`Number`] or `null`
- Default: `null`
- Example:
```js
// Don't include location points with an accuracy exceeding 100 meters
window.owntracks.config = {
filters: {
minAccuracy: 100,
},
};
```
### `ignorePingLocation`
Remove the `ping/ping` location from the fetched data. This is useful when using the
`owntracks/recorder` Docker image which has it [enabled for health checks by default](https://github.com/owntracks/recorder/issues/195#issuecomment-304004436).
- Type: [`Boolean`]
- Default: `true`
- Example:
```js
// Don't ignore ping/ping location. Not sure why you'd do this :)
window.owntracks.config = {
ignorePingLocation: false,
};
```
### `locale`
The locale to use for the user interface, this affects the language and date/time
formats.
Available languages:
- `de` (German)
- `en` (English)
- `es` (Spanish)
You can use formats like `en-GB`, `en-US`, `de-DE`.
- Type: [`String`]
- Default: `"en"`
### `map.attribution`
Attribution for map tiles.
- Type: [`String`] (may contain HTML)
- Default: `"&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors"`
- Example:
```js
// Make sure to add proper attribution!
window.owntracks.config = {
map: {
attribution: "Map tiles &copy; MyTileServerProvider",
},
};
```
### `map.circle`
Location accuracy indicator configuation. `color` and `fillColor` default to
`primaryColor` if `null`. See [Vue2Leaflet `l-circle` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-circle/)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: null,
fillColor: null,
fillOpacity: 0.2
}
```
### `map.circleMarker`
Location point marker configuation. `color` defaults to `primaryColor` if `null`. See
[Vue2Leaflet `l-circle-marker` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-circle-marker/)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: null,
fillColor: "#fff",
fillOpacity: 1,
radius: 4
}
```
### `map.controls.scale.display`
Whether to show scale control or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.scale.imperial`
Whether to show an imperial scale (ft) or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.scale.maxWidth`
Maximum width of the scale control in pixels.
- Type: [`Number`]
- Default: `200`
### `map.controls.scale.metric`
Whether to show an metric scale (m) or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.scale.position`
Scale control position on the map. See [Leaflet control position documentation](https://leafletjs.com/reference-1.5.0.html#control-position)
for all possible values.
- Type: [`String`]
- Default: `"bottomleft"`
### `map.controls.zoom.display`
Whether to show zoom control or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.zoom.position`
Zoom control position on the map. See [Leaflet control position documentation](https://leafletjs.com/reference-1.5.0.html#control-position)
for all possible values.
- Type: [`String`]
- Default: `"topleft"`
### `map.heatmap.blur`
Heatmap blur radius.
- Type: [`Number`]
- Default: `15`
### `map.heatmap.gradient`
Mapping of values between 0 and 1 to different colors. Defaults to [`simpleheat`'s default gradient](https://github.com/mourner/simpleheat/blob/c1998c36fa2f9a31350371fd42ee30eafcc78f9c/simpleheat.js#L22-L28)
if `null`.
- Type: [`Object`] or `null`
- Default: `null`
### `map.heatmap.max`
Heatmap max data value.
- Type: [`Number`]
- Default: `20`
### `map.heatmap.radius`
Heatmap point radius.
- Type: [`Number`]
- Default: `25`
### `map.layers.heatmap`
Initial visibility of the heatmap layer.
- Type: [`Boolean`]
- Default: `false`
### `map.layers.last`
Initial visibility of the last locations layer.
- Type: [`Boolean`]
- Default: `true`
### `map.layers.line`
Initial visibility of the line layer.
- Type: [`Boolean`]
- Default: `true`
### `map.layers.points`
Initial visibility of the points layer.
- Type: [`Boolean`]
- Default: `false`
### `map.maxNativeZoom`
This is being used to fetch tiles in different resolutions - set to the highest value
the configured tileserver supports.
- Type: [`Number`]
- Default: `19`
### `map.maxPointDistance`
Maximum distance (in meters) between points for them to be part of the the same line.
This avoids straight lines going across the map when there's a ceartain distance between
two points (which often indicates that they're not related). Set to `null` to disable
splitting into separate lines.
- Type: [`Number`] or `null`
- Default: `null`
- Example:
```js
// Don't connect points with a distance of more than 1km
window.owntracks.config = {
map: {
maxPointDistance: 1000,
},
};
```
### `map.maxZoom`
Allow zooming closer than the tile server supports, which will result in (slightly)
blurry tiles on higher zoom levels. Set to the same value as [`map.maxNativeZoom`](#map.maxNativeZoom)
to disable.
- Type: [`Number`]
- Default: `21`
### `map.polyline`
Location point marker configuation. `color` defaults to `primaryColor` if `null`. See
[Vue2Leaflet `l-polyline` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-polyline/)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: null,
fillColor: "transparent"
}
```
### `map.url`
Tile server URL. For more information see [Leaflet tile layer documentation](https://leafletjs.com/reference-1.5.0.html#tilelayer-url-template)
and [this Wikipedia article](https://en.wikipedia.org/wiki/Tiled_web_map).
- Type: [`String`]
- Default: `"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"`
- Example:
```js
// Use dark HDPI tiles from Mapbox
window.owntracks.config = {
map: {
url:
"https://api.mapbox.com/v4/mapbox.dark/{z}/{x}/{y}@2x.png?access_token=xxxxxxxxxxxxxxxx",
},
};
```
### `onLocationChange.fitView`
Whether to re-fit the map's content into view or not when a location update is received.
This can be useful if you're showing live locations and don't want them to "leave" the map.
- Type: [`Boolean`]
- Default: `false`
### `onLocationChange.reloadHistory`
Whether to reload the location history (of selected date range) or not when a location
update is received.
- Type: [`Boolean`]
- Default: `false`
### `primaryColor`
Primary color for the user interface (navigation bar and various map elements).
- Type: [`String`] ([CSS `<color>`])
- Default: `"#3f51b5"` (primary color from the OwnTracks Android app)
- Example:
```js
// Set the UI's primary color to 'rebeccapurple'
window.owntracks.config = {
primaryColor: "rebeccapurple",
};
```
### `selectedDevice`
Initial selected device. All devices will be shown by default if `null`. Will be ignored
if [`selectedUser`](#selectedUser) is `null`.
Only data for the selected user/device will be fetched, so you can use this to limit the
amount of data fetched after page load.
- Type: [`String`] or `null`
- Default: `null`
- Example:
```js
// Select the device 'phone' from user 'foo' by default
window.owntracks.config = {
selectedUser: "foo",
selectedDevice: "phone",
};
```
### `selectedUser`
Initial selected user. All users will be shown by default if `null`.
Only data for the selected user/device will be fetched, so you can use this to limit the
amount of data fetched after page load.
- Type: [`String`] or `null`
- Default: `null`
- Example:
```js
// Select all devices from user 'foo' by default
window.owntracks.config = {
selectedUser: "foo",
};
```
### `showDistanceTravelled`
Whether to calculate and show the travelled distance of the last fetched data in the
header bar. `maxPointDistance` is being takein into account, if a distance between two
subsequent points is greater than `maxPointDistance`, it will not contibute to the
calculated travelled distance.
- Type: [`Boolean`]
- Default: `true`
### `startDateTime`
Initial start date and time (browser timezone) for fetched data.
- Type: [`Date`]
- Default: one month ago, 00:00:00
- Example:
```js
// Data will be fetched from the first day of the current month
const startDateTime = new Date();
startDateTime.setHours(0, 0, 0, 0);
startDateTime.setDate(1);
window.owntracks.config = {
startDateTime,
};
```
### `verbose`
Whether to enable verbose mode or not.
- Type: [`Boolean`]
- Default: `false`
[`boolean`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[`date`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
[`number`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number
[`object`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object
[`string`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String
[css `<color>`]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
[`fetch()` docs on mdn]: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 653 KiB

View File

@@ -1,224 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, height=device-height, user-scalable=no, initial-scale=1.0" />
<title>OwnTracks</title>
<link href="static/style.css" rel="stylesheet">
<link href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Noto+Sans" rel="stylesheet">
<link href="https://unpkg.com/@mdi/font@3.5.95/css/materialdesignicons.min.css" rel="stylesheet">
</head>
<body>
<div id="app">
<header>
<nav>
<div class="nav-item">
<button
class="button button-outline"
title="Automatically center the map view and zoom in to relevant data"
@click="centerView"
>
Center View
</button>
</div>
<div class="nav-item">
<span class="mdi mdi-24px mdi-layers"></span>
<div class="dropdown">
<button class="dropdown-button button" title="Show/hide layers">
Layer Settings
</button>
<div class="dropdown-body">
<label tabindex="0">
<input type="checkbox" v-model="showLastLocations">
Show last known locations
</label>
<label tabindex="0">
<input type="checkbox" v-model="showLocationHistoryLine">
Show location history (line)
</label>
<label tabindex="0">
<input type="checkbox" v-model="showLocationHistoryPoints">
Show location history (points)
</label>
<label tabindex="0">
<input type="checkbox" v-model="showLocationHeatmap">
Show location heatmap
</label>
</div>
</div>
</div>
<div class="nav-item">
<span class="mdi mdi-24px mdi-calendar-range"></span>
<vuejs-datepicker
v-model="startDate"
:use-utc="true"
:disabled-dates="startDateDisabledDates"
title="Select start date"
></vuejs-datepicker>
to
<vuejs-datepicker
v-model="endDate"
:use-utc="true"
:disabled-dates="endDateDisabledDates"
title="Select end date"
></vuejs-datepicker>
</div>
<div class="nav-item">
<span class="mdi mdi-24px mdi-account"></span>
<select v-model="selectedUser" class="dropdown-button button" title="Select user">
<option value="">
Show All
</option>
<option v-for="user in users" :value="user">
{{ user }}
</option>
</select>
</div>
<div v-if="selectedUser" class="nav-item">
<span class="mdi mdi-24px mdi-cellphone-link"></span>
<select v-model="selectedDevice" class="dropdown-button button" title="Select device">
<option value="">
Show All
</option>
<option v-for="device in devices[selectedUser]" :value="device">
{{ device }}
</option>
</select>
</div>
</nav>
<nav class="nav-shrink">
<div class="nav-item">
<button
class="button button-flat button-icon"
title="Download raw data"
@click="showDownloadModal = !showDownloadModal"
>
<span class="mdi mdi-24px mdi-download"></span>
</button>
</div>
<div class="nav-item">
<button
class="button button-flat button-icon"
title="Information"
@click="showInformationModal = !showInformationModal"
>
<span class="mdi mdi-24px mdi-information-outline"></span>
</button>
</div>
</nav>
</header>
<main>
<l-map ref="map" :zoom="map.zoom" :center="map.center">
<l-tile-layer
:url="map.url"
:attribution="map.attribution"
:options="{maxNativeZoom: map.maxNativeZoom, maxZoom: map.maxZoom}"
></l-tile-layer>
<l-circle
v-if="showLastLocations"
v-for="l in lastLocations"
:key="`${l.topic}-circle`"
:lat-lng="{lat: l.lat, lng: l.lon}"
:radius="l.acc"
:color="map.circle.color"
:fill-color="map.circle.fillColor"
:fill-opacity="map.circle.fillOpacity"
></l-circle>
<l-marker
v-if="showLastLocations"
v-for="l in lastLocations"
:key="`${l.topic}-marker`"
:lat-lng="[l.lat, l.lon]"
>
<location-popup
:user="l.username"
:device="l.device"
:name="l.name"
:face="l.face"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
></location-popup>
</l-marker>
<l-polyline
v-if="showLocationHistoryLine"
:lat-lngs="locationHistoryLatLngs"
:color="map.polyline.color"
:fill-color="map.polyline.fillColor"
></l-polyline>
<template v-if="showLocationHistoryPoints">
<template v-for="(userDevices, user) in locationHistory">
<template v-for="(deviceLocations, device) in userDevices">
<l-circle-marker
v-for="(l, n) in deviceLocations"
:key="`${user}-${device}-${n}`"
:lat-lng="[l.lat, l.lon]"
:radius="map.circleMarker.radius"
:color="map.circleMarker.color"
:fill-color="map.circleMarker.fillColor"
:fill-opacity="map.circleMarker.fillOpacity"
>
<location-popup
:user="user"
:device="device"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
></location-popup>
</l-circle-marker>
</template>
</template>
</template>
<template v-if="showLocationHeatmap">
<l-heatmap
v-if="locationHistoryLatLngs.length"
:lat-lng="locationHistoryLatLngs"
:max="map.heatmap.max"
:radius="map.heatmap.radius"
:blur="map.heatmap.blur"
:gradient="map.heatmap.gradient"
></l-heatmap>
</template>
</l-map>
</main>
<modal :visible="showDownloadModal" @close="showDownloadModal = false">
Not implemented.
</modal>
<modal :visible="showInformationModal" @close="showInformationModal = false">
<b>OwnTracks {{ information.ownTracks.version }}</b>
<ul>
<li><a :href="information.ownTracksUi.sourceCodeUrl">OwnTracks UI Source Code</a></li>
<li><a :href="information.ownTracks.documentationUrl">OwnTracks Recorder Documentation</a></li>
<li><a :href="information.ownTracks.sourceCodeUrl">OwnTracks Recorder Source Code</a></li>
<li><a :href="information.ownTracks.twitterUrl">OwnTracks Twitter</a></li>
</ul>
</modal>
</div>
<script src="https://unpkg.com/vue@2.5.22/dist/vue.min.js"></script>
<script src="https://unpkg.com/vuejs-datepicker@1.5.4/dist/vuejs-datepicker.min.js"></script>
<script src="https://unpkg.com/leaflet@1.4.0/dist/leaflet.js"></script>
<script src="https://unpkg.com/vue2-leaflet@1.2.3/dist/vue2-leaflet.min.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script src="https://unpkg.com/deepmerge@3.2.0/dist/umd.js"></script>
<script src="static/components/vue-leaflet-heatmap.js"></script>
<script src="static/components/location-popup.js"></script>
<script src="static/components/modal.js"></script>
<script src="static/config/default.js"></script>
<script src="static/config/custom.js"></script>
<script src="static/main.js"></script>
</body>
</html>

23
jest.config.js Normal file
View File

@@ -0,0 +1,23 @@
module.exports = {
moduleFileExtensions: ["js", "jsx", "json", "vue"],
transform: {
"^.+\\.vue$": "vue-jest",
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
"jest-transform-stub",
"^.+\\.jsx?$": "babel-jest",
},
transformIgnorePatterns: ["/node_modules/"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
snapshotSerializers: ["jest-serializer-vue"],
testMatch: [
"**/tests/**/*.test.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)",
],
testURL: "http://localhost/",
watchPlugins: [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname",
],
setupFiles: ["<rootDir>/tests/setup.js"],
};

9
jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules"]
}

65
package.json Normal file
View File

@@ -0,0 +1,65 @@
{
"name": "owntracks-ui",
"version": "2.5.0",
"author": {
"name": "Linus Groh",
"email": "mail@linusgroh.de"
},
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"cors-proxy": "node scripts/corsProxy.js",
"format:js": "vue-cli-service lint",
"format:md": "prettier --write '{*.md,docs/**/*.md,src/**/*.md}'",
"i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales './src/locales/**/*.json'",
"lint:js": "vue-cli-service lint --no-fix",
"lint:md": "prettier --check '{*.md,docs/**/*.md,src/**/*.md}'",
"test": "vue-cli-service test:unit"
},
"dependencies": {
"clipboard-copy": "^3.1.0",
"core-js": "^3.6.5",
"deepmerge": "^4.2.2",
"leaflet": "^1.7.1",
"leaflet.heat": "^0.2.0",
"moment": "^2.27.0",
"vue": "^2.6.12",
"vue-ctk-date-time-picker": "^2.5.0",
"vue-feather-icons": "^5.1.0",
"vue-i18n": "^8.21.0",
"vue-js-modal": "^1.3.33",
"vue-mq": "^1.0.1",
"vue-outside-events": "^1.1.3",
"vue-router": "^3.4.3",
"vue2-leaflet": "^2.5.2",
"vuex": "^3.5.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.4",
"@vue/cli-plugin-eslint": "~4.5.4",
"@vue/cli-plugin-unit-jest": "~4.5.4",
"@vue/cli-service": "~4.5.4",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/test-utils": "1.0.5",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.3.0",
"cors-anywhere": "^0.4.3",
"eslint": "^7.8.1",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-vue": "^6.2.2",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^10.3.0",
"moment-locales-webpack-plugin": "^1.2.0",
"prettier": "^2.1.1",
"sass": "^1.26.10",
"sass-loader": "^10.0.2",
"vue-cli-plugin-i18n": "~1.0.1",
"vue-template-compiler": "^2.6.12"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/owntracks/frontend.git"
}
}

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

View File

@@ -0,0 +1,3 @@
// Here you can overwite the default configuration values
window.owntracks = window.owntracks || {};
window.owntracks.config = {};

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

18
public/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>OwnTracks UI</title>
</head>
<body>
<noscript>
<strong>We're sorry but OwnTracks doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script src="<%= BASE_URL %>config/config.js"></script>
<!-- built files will be auto injected -->
</body>
</html>

25
scripts/corsProxy.js Normal file
View File

@@ -0,0 +1,25 @@
const corsProxy = require("cors-anywhere");
const host = process.env.OT_PROXY_HOST || "0.0.0.0";
const port = process.env.OT_PROXY_PORT || 8888;
const username = process.env.OT_BASIC_AUTH_USERNAME || null;
const password = process.env.OT_BASIC_AUTH_PASSWORD || null;
const options = {
httpProxyOptions: {
ws: true,
},
};
if (username !== null && password !== null) {
console.log(`Basic auth for user ${username} enabled`);
options.setHeaders = {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString(
"base64"
)}`,
};
}
corsProxy.createServer(options).listen(port, host, () => {
console.log(`Running CORS Anywhere on http://${host}:${port}`);
});

97
src/App.vue Normal file
View File

@@ -0,0 +1,97 @@
<template>
<div id="app">
<AppHeader />
<main>
<router-view />
</main>
<DownloadModal />
<InformationModal />
<LoadingModal />
</div>
</template>
<style lang="scss">
@import "styles/main";
</style>
<script>
import { mapActions } from "vuex";
import * as types from "@/store/mutation-types";
import { log } from "@/logging";
import AppHeader from "@/components/AppHeader";
import DownloadModal from "@/components/modals/Download";
import InformationModal from "@/components/modals/Information";
import LoadingModal from "@/components/modals/Loading";
export default {
components: { AppHeader, DownloadModal, InformationModal, LoadingModal },
created() {
document.documentElement.style.setProperty(
"--color-primary",
this.$config.primaryColor
);
this.populateStateFromQuery(this.$route.query);
this.loadData();
// Update URL query params when relevant values changes
this.$store.subscribe((mutation) => {
if (
[
types.SET_SELECTED_USER,
types.SET_SELECTED_DEVICE,
types.SET_START_DATE_TIME,
types.SET_END_DATE_TIME,
types.SET_MAP_CENTER,
types.SET_MAP_ZOOM,
types.SET_MAP_LAYER_VISIBILITY,
].includes(mutation.type)
) {
this.updateUrlQuery();
}
if (mutation.type === types.SET_IS_LOADING) {
this.$store.state.isLoading
? this.$modal.show("loading")
: this.$modal.hide("loading");
}
});
// Initially update URL query params from state
this.updateUrlQuery();
},
methods: {
...mapActions(["populateStateFromQuery", "loadData"]),
/**
* Update all URL query parameters. This is called whenever any
* of the relevant values change in the Vuex store.
*/
updateUrlQuery() {
const {
map,
startDateTime: start,
endDateTime: end,
selectedUser: user,
selectedDevice: device,
} = this.$store.state;
const activeLayers = Object.keys(map.layers).filter(
(key) => map.layers[key] === true
);
const query = {
lat: map.center.lat,
lng: map.center.lng,
zoom: map.zoom,
start,
end,
...(user !== null && { user }),
...(user !== null && device !== null && { device }),
...(activeLayers.length > 0 && { layers: activeLayers.join(",") }),
};
log("STATE", "Updating URL query from state");
log(
"STATE",
JSON.parse(JSON.stringify({ map, start, end, user, device }))
);
this.$router.replace({ query }).catch(() => {}); // https://github.com/vuejs/vue-router/issues/2872#issuecomment-519073998
},
},
};
</script>

216
src/api.js Normal file
View File

@@ -0,0 +1,216 @@
import config from "@/config";
import { log, logLevels } from "@/logging";
import { getApiUrl, getLocationHistoryCount } from "@/util";
/**
* Fetch an API resource.
*
* @param {String} path API resource path
* @param {Object} [params] Query parameters
* @returns {Promise} Promise returned by the fetch function
*/
const fetchApi = (path, params = {}) => {
const url = getApiUrl(path);
Object.keys(params).forEach((key) =>
url.searchParams.append(key, params[key])
);
log("HTTP", `GET ${url.href}`);
return fetch(url.href, config.api.fetchOptions).catch((error) =>
log("HTTP", error, logLevels.ERROR)
);
};
/**
* Get the recorder's version.
*
* @returns {String} Version
*/
export const getVersion = async () => {
const response = await fetchApi("/api/0/version");
const json = await response.json();
const version = json.version;
log("API", () => `[getVersion] ${version}`);
return version;
};
/**
* Get all users.
*
* @returns {User[]} Array of usernames
*/
export const getUsers = async () => {
const response = await fetchApi("/api/0/list");
const json = await response.json();
const users = json.results;
log("API", () => `[getUsers] Fetched ${users.length} users`);
return users;
};
/**
* Get all devices for the provided users.
*
* @param {User[]} users Array of usernames
* @returns {{User: Device[]}}
* Object mapping each username to an array of device names
*/
export const getDevices = async (users) => {
const devices = {};
await Promise.all(
users.map(async (user) => {
const response = await fetchApi(`/api/0/list`, { user });
const json = await response.json();
const userDevices = json.results;
devices[user] = userDevices;
})
);
log("API", () => {
const devicesCount = Object.keys(devices)
.map((user) => devices[user].length)
.reduce((a, b) => a + b, 0);
return (
`[getDevices] Fetched ${devicesCount} ` +
`devices for ${users.length} users`
);
});
return devices;
};
/**
* Get last locations for a specific or all user/device.
*
* @param {User} [user] Get last locations of all devices from this user
* @param {Device} [device] Get last location of specific device
* @returns {OTLocation[]} Array of last location objects
*/
export const getLastLocations = async (user, device) => {
const params = {};
if (user) {
params["user"] = user;
if (device) {
params["device"] = device;
}
}
const response = await fetchApi("/api/0/last", params);
const json = await response.json();
const lastLocations = json;
log(
"API",
() => `[getLastLocations] Fetched ${lastLocations.length} last locations`
);
return lastLocations;
};
/**
* Get the location history of a specific user/device.
*
* @param {User} user Username
* @param {Device} device Device name
* @param {String} start Start date and time in UTC
* @param {String} end End date and time in UTC
* @returns {OTLocation[]} Array of location history objects
*/
export const getUserDeviceLocationHistory = async (
user,
device,
start,
end
) => {
const response = await fetchApi("/api/0/locations", {
from: start,
to: end,
user,
device,
format: "json",
});
const json = await response.json();
const userDeviceLocationHistory = json.data;
log(
"API",
() =>
`[getUserDeviceLocationHistory] Fetched ` +
`${userDeviceLocationHistory.length} locations for ` +
`${user}/${device} from ${start} - ${end}`
);
return userDeviceLocationHistory;
};
/**
* Get the location history of multiple devices.
*
* @param {{User: Device[]}} devices
* Devices of which the history should be fetched
* @param {String} start Start date and time in UTC
* @param {String} end End date and time in UTC
* @returns {LocationHistory} Location history
*/
export const getLocationHistory = async (devices, start, end) => {
const locationHistory = {};
await Promise.all(
Object.keys(devices).map(async (user) => {
locationHistory[user] = {};
await Promise.all(
devices[user].map(async (device) => {
locationHistory[user][device] = await getUserDeviceLocationHistory(
user,
device,
start,
end
);
})
);
})
);
log("API", () => {
const locationHistoryCount = getLocationHistoryCount(locationHistory);
return (
"[getLocationHistory] Fetched " +
`${locationHistoryCount} locations in total`
);
});
return locationHistory;
};
/**
* Connect to the WebSocket API, reconnect when necessary and handle received
* messages.
*
* @param {WebSocketLocationCallback} [callback] Callback for location messages
*/
export const connectWebsocket = async (callback) => {
let url = getApiUrl("/ws/last");
url.protocol = url.protocol.replace("http", "ws");
url = url.href;
const ws = new WebSocket(url);
log("WS", `Connecting to ${url}`);
ws.onopen = () => {
log("WS", "Connected");
ws.send("LAST");
};
ws.onclose = (event) => {
log(
"WS",
`Disconnected unexpectedly (reason: ${
event.reason || "unknown"
}). Reconnecting in one second.`,
logLevels.WARNING
);
setTimeout(connectWebsocket, 1000);
};
ws.onmessage = async (msg) => {
if (msg.data) {
try {
const data = JSON.parse(msg.data);
if (data._type === "location") {
log("WS", "Location update received");
callback && (await callback());
}
} catch (err) {
if (msg.data !== "LAST") {
log("WS", err, logLevels.ERROR);
}
}
} else {
log("WS", "Ping");
}
};
};

View File

@@ -0,0 +1,259 @@
<template>
<header :class="$mq === 'sm' ? 'header-sm' : null">
<div v-if="$mq === 'sm'" class="header-item">
<button
class="button button-flat button-icon"
@click="showMobileNav = !showMobileNav"
>
<MenuIcon size="1x" aria-hidden="true" role="img" />
</button>
</div>
<nav
v-if="$mq === 'sm' ? showMobileNav : true"
class="header-item header-item-grow"
:class="$mq === 'sm' ? 'nav-sm' : null"
>
<div class="nav-item">
<CrosshairIcon
v-if="$mq === 'sm'"
size="1x"
aria-hidden="true"
role="img"
/>
<button
class="button button-outline"
:title="
$t('Automatically center the map view and zoom in to relevant data')
"
@click="$root.$emit('fitView')"
>
{{ $t("Fit view") }}
</button>
</div>
<div class="nav-item">
<LayersIcon size="1x" aria-hidden="true" role="img" />
<Dropdown :label="$t('Layer settings')" :title="$t('Show/hide layers')">
<label v-for="option in layerSettingsOptions" :key="option.layer">
<input
type="checkbox"
:checked="map.layers[option.layer]"
@change="
setMapLayerVisibility({
layer: option.layer,
visibility: $event.target.checked,
})
"
/>
{{ option.label }}
</label>
</Dropdown>
</div>
<div class="nav-item">
<CalendarIcon size="1x" aria-hidden="true" role="img" />
<VueCtkDateTimePicker
v-model="startDateTime"
:format="DATE_TIME_FORMAT"
:color="$config.primaryColor"
:locale="$config.locale"
:max-date="endDateTime"
:button-now-translation="$t('Now')"
>
<button
type="button"
class="dropdown-button button"
:title="$t('Select start date')"
/>
</VueCtkDateTimePicker>
<span>{{ $t("to") }}</span>
<VueCtkDateTimePicker
v-model="endDateTime"
:format="DATE_TIME_FORMAT"
:color="$config.primaryColor"
:locale="$config.locale"
:min-date="startDateTime"
:button-now-translation="$t('Now')"
>
<button
type="button"
class="dropdown-button button"
:title="$t('Select end date')"
/>
</VueCtkDateTimePicker>
</div>
<div class="nav-item">
<UserIcon size="1x" aria-hidden="true" role="img" />
<select
v-model="selectedUser"
class="dropdown-button button"
:title="$t('Select user')"
>
<option :value="null">
{{ $t("Show all") }}
</option>
<option v-for="user in users" :value="user" :key="user">
{{ user }}
</option>
</select>
</div>
<div v-if="selectedUser" class="nav-item">
<SmartphoneIcon size="1x" aria-hidden="true" role="img" />
<select
v-model="selectedDevice"
class="dropdown-button button"
:title="$t('Select device')"
>
<option :value="null">
{{ $t("Show all") }}
</option>
<option
v-for="device in devices[selectedUser]"
:value="device"
:key="`${selectedUser}-${device}`"
>
{{ device }}
</option>
</select>
</div>
</nav>
<nav class="header-item header-item-right">
<div
v-if="$config.showDistanceTravelled && distanceTravelled"
class="nav-item"
:title="$t('Distance travelled')"
>
{{ humanReadableDistance(distanceTravelled) }}
</div>
<div class="nav-item">
<button
class="button button-flat button-icon"
:title="$t('Download raw data')"
@click="$modal.show('download')"
>
<DownloadIcon
size="1x"
:aria-label="$t('Download raw data')"
role="img"
/>
</button>
</div>
<div class="nav-item">
<button
class="button button-flat button-icon"
:title="$t('Information')"
@click="$modal.show('information')"
>
<InfoIcon size="1x" :aria-label="$t('Information')" role="img" />
</button>
</div>
</nav>
</header>
</template>
<script>
import moment from "moment";
import { mapActions, mapGetters, mapMutations, mapState } from "vuex";
import {
CalendarIcon,
CrosshairIcon,
DownloadIcon,
InfoIcon,
LayersIcon,
MenuIcon,
SmartphoneIcon,
UserIcon,
} from "vue-feather-icons";
import VueCtkDateTimePicker from "vue-ctk-date-time-picker";
import "vue-ctk-date-time-picker/dist/vue-ctk-date-time-picker.css";
import Dropdown from "@/components/Dropdown";
import { DATE_TIME_FORMAT } from "@/constants";
import * as types from "@/store/mutation-types";
import { humanReadableDistance } from "@/util";
export default {
components: {
CalendarIcon,
CrosshairIcon,
DownloadIcon,
InfoIcon,
LayersIcon,
MenuIcon,
SmartphoneIcon,
UserIcon,
VueCtkDateTimePicker,
Dropdown,
},
data() {
return {
DATE_TIME_FORMAT,
layerSettingsOptions: [
{ layer: "last", label: this.$t("Show last known locations") },
{ layer: "line", label: this.$t("Show location history (line)") },
{ layer: "points", label: this.$t("Show location history (points)") },
{ layer: "heatmap", label: this.$t("Show location heatmap") },
],
showMobileNav: false,
};
},
computed: {
...mapState(["users", "devices", "map", "distanceTravelled"]),
selectedUser: {
get() {
return this.$store.state.selectedUser;
},
set(value) {
this.setSelectedUser(value);
},
},
selectedDevice: {
get() {
return this.$store.state.selectedDevice;
},
set(value) {
this.setSelectedDevice(value);
},
},
startDateTime: {
get() {
return moment
.utc(this.$store.state.startDateTime, DATE_TIME_FORMAT)
.local()
.format(DATE_TIME_FORMAT);
},
set(value) {
this.setStartDateTime(
moment(value, DATE_TIME_FORMAT).utc().format(DATE_TIME_FORMAT)
);
},
},
endDateTime: {
get() {
return moment
.utc(this.$store.state.endDateTime, DATE_TIME_FORMAT)
.local()
.format(DATE_TIME_FORMAT);
},
set(value) {
this.setEndDateTime(
moment(value, DATE_TIME_FORMAT)
.set("seconds", 59)
.utc()
.format(DATE_TIME_FORMAT)
);
},
},
},
methods: {
...mapMutations({
setMapLayerVisibility: types.SET_MAP_LAYER_VISIBILITY,
}),
...mapActions([
"setSelectedUser",
"setSelectedDevice",
"setStartDateTime",
"setEndDateTime",
]),
humanReadableDistance,
},
};
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="dropdown" v-focus-outside="hide" v-click-outside="hide">
<button class="dropdown-button button" :title="title" @click="toggle">
{{ label }}
</button>
<div v-if="active" class="dropdown-body">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
required: true,
},
title: {
type: String,
default: "",
},
},
data() {
return {
active: false,
};
},
methods: {
toggle() {
this.active = !this.active;
},
hide() {
this.active = false;
},
},
};
</script>

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

@@ -0,0 +1,165 @@
<template>
<LPopup :options="options">
<div class="device">{{ deviceName }}</div>
<div class="wrapper">
<img
v-if="face"
:src="faceImageDataURI"
:alt="$t('Image of {deviceName}', { deviceName })"
:title="$t('Image of {deviceName}', { deviceName })"
/>
<ul class="info-list">
<li :title="$t('Timestamp')">
<ClockIcon size="1x" aria-hidden="true" role="img" />
{{ new Date(timestamp * 1000).toLocaleString($config.locale) }}
</li>
<li :title="$t('Location')">
<MapPinIcon size="1x" aria-hidden="true" role="img" />
{{ lat }}
<br />
{{ lon }}
<br />
{{ alt }}m
</li>
<li v-if="address" :title="$t('Address')">
<HomeIcon size="1x" aria-hidden="true" role="img" />
{{ address }}
</li>
<li v-if="typeof battery === 'number'" :title="$t('Battery')">
<BatteryIcon size="1x" aria-hidden="true" role="img" />
{{ battery }} %
</li>
<li v-if="typeof speed === 'number'" :title="$t('Speed')">
<ZapIcon size="1x" aria-hidden="true" role="img" />
{{ speed }} km/h
</li>
</ul>
</div>
<div v-if="regions.length" class="regions">
{{ $t("Regions:") }}
{{ regions.join(", ") }}
</div>
</LPopup>
</template>
<style lang="scss" scoped>
.device {
display: inline-block;
position: relative;
top: -5px;
color: var(--color-primary);
font-weight: bold;
}
.wrapper {
display: flex;
margin-top: 10px;
margin-right: 20px;
img {
align-self: start;
margin-right: 20px;
}
}
.regions {
border-top: 1px solid var(--color-separator);
margin-top: 15px;
padding-top: 15px;
}
</style>
<script>
import {
BatteryIcon,
ClockIcon,
HomeIcon,
MapPinIcon,
ZapIcon,
} from "vue-feather-icons";
import { LPopup } from "vue2-leaflet";
export default {
name: "LDeviceLocationPopup",
components: {
BatteryIcon,
ClockIcon,
HomeIcon,
MapPinIcon,
ZapIcon,
LPopup,
},
props: {
user: {
type: String,
default: "",
},
device: {
type: String,
default: "",
},
name: {
type: String,
default: "",
},
face: {
type: String,
default: null,
},
timestamp: {
type: Number,
default: 0,
},
lat: {
type: Number,
default: 0,
},
lon: {
type: Number,
default: 0,
},
alt: {
type: Number,
default: 0,
},
address: {
type: String,
default: null,
},
battery: {
type: Number,
default: null,
},
speed: {
type: Number,
default: null,
},
regions: {
type: Array,
default: () => [],
},
options: {
type: Object,
default: () => {},
},
},
computed: {
/**
* Return the face image as a data URI string which can be used for an
* image's src attribute.
*
* @returns {String} base64-encoded face image data URI
*/
faceImageDataURI() {
return `data:image/png;base64,${this.face}`;
},
/**
* Return the device name for displaying with <user identifier>/<device
* identifier> as fallback.
*
* @returns {String} device name for displaying
*/
deviceName() {
return this.name ? this.name : `${this.user}/${this.device}`;
},
},
};
</script>

138
src/components/LHeatmap.vue Normal file
View File

@@ -0,0 +1,138 @@
<template>
<div />
</template>
<style scoped>
div {
display: none;
}
</style>
<script>
// See https://github.com/KoRiGaN/Vue2Leaflet/blob/e0cf0f29bc519f0a70f0f1eb6e579f947e7ea4ce/src/utils/utils.js
// to understand the `custom` attribute of each prop, how the `set<Prop>`
// methods are being used and why `mapObject` has to be named `mapObject`.
import { findRealParent, propsBinder } from "vue2-leaflet";
import L, { DomEvent } from "leaflet";
import "leaflet.heat";
const props = {
latLng: {
type: Array,
default: () => [],
custom: false,
},
minOpacity: {
type: Number,
custom: true,
default: 0.05,
},
maxZoom: {
type: Number,
custom: true,
default: 18,
},
radius: {
type: Number,
custom: true,
default: 25,
},
blur: {
type: Number,
custom: true,
default: 15,
},
max: {
type: Number,
custom: true,
default: 1.0,
},
gradient: {
type: Object,
custom: true,
default: null,
},
visible: {
type: Boolean,
custom: true,
default: true,
},
activated: {
type: Boolean,
custom: true,
default: true,
},
};
export default {
props,
mounted() {
const options = {};
if (this.minOpacity) {
options.minOpacity = this.minOpacity;
}
if (this.maxZoom) {
options.maxZoom = this.maxZoom;
}
if (this.radius) {
options.radius = this.radius;
}
if (this.blur) {
options.blur = this.blur;
}
if (this.max) {
options.max = this.max;
}
if (this.gradient) {
options.gradient = this.gradient;
}
this.mapObject = L.heatLayer(this.latLng, options);
DomEvent.on(this.mapObject, this.$listeners);
propsBinder(this, this.mapObject, props);
this.parentContainer = findRealParent(this.$parent);
this.parentContainer.addLayer(this, !this.visible);
this.$watch(
"latLng",
(newVal) => {
this.mapObject.setLatLngs(newVal);
},
{ deep: true }
);
},
beforeDestroy() {
this.parentContainer.removeLayer(this);
},
methods: {
setMinOpacity(minOpacity) {
this.mapObject.setOptions({ minOpacity });
},
setMaxZoom(maxZoom) {
this.mapObject.setOptions({ maxZoom });
},
setRadius(radius) {
this.mapObject.setOptions({ radius });
},
setBlur(blur) {
this.mapObject.setOptions({ blur });
},
setMax(max) {
this.mapObject.setOptions({ max });
},
setGradient(gradient) {
this.mapObject.setOptions({ gradient });
},
setVisible(newVal, oldVal) {
if (newVal === oldVal) return;
if (newVal) {
this.parentContainer.addLayer(this);
} else {
this.parentContainer.removeLayer(this);
}
},
addLatLng(value) {
this.mapObject.addLatLng(value);
},
},
};
</script>

View File

@@ -0,0 +1,110 @@
<template>
<modal name="download" adaptive>
<pre class="data"><code>{{ data }}</code></pre>
<div class="options">
<input
v-model="options.minifyJson"
type="checkbox"
id="option-minify-json"
/>
<label for="option-minify-json">
{{ $t("Minify JSON") }}
</label>
</div>
<div class="buttons">
<button
class="button button-outline button-primary"
:title="$t('Copy to clipboard')"
@click="copy"
>
{{ $t("Copy to clipboard") }}
</button>
<button
class="button button-primary"
:title="$t('Download')"
@click="download"
>
{{ $t("Download") }}
</button>
</div>
</modal>
</template>
<style lang="scss" scoped>
.data {
max-height: 300px;
}
.options {
margin-top: 30px;
}
.buttons {
display: flex;
margin-top: 30px;
button {
flex: 1;
&:first-child {
margin-right: 10px;
}
&:last-child {
margin-left: 10px;
}
}
}
</style>
<script>
import { mapState } from "vuex";
import copy from "clipboard-copy";
import { download } from "@/util";
export default {
data() {
return {
options: {
minifyJson: false,
},
};
},
computed: {
...mapState([
"startDateTime",
"endDateTime",
"selectedUser",
"selectedDevice",
"locationHistory",
]),
data() {
return this.locationHistory;
},
},
methods: {
copy() {
const data = JSON.stringify(
this.data,
null,
this.options.minifyJson ? 0 : 2
);
copy(data);
},
download() {
const data = JSON.stringify(
this.data,
null,
this.options.minifyJson ? 0 : 2
);
const start = this.startDateTime;
const end = this.endDateTime;
const user = this.selectedUser ? `_${this.selectedUser}` : "";
const device = this.selectedDevice ? `_${this.selectedDevice}` : "";
const filename = `data_${start}_${end}${user}${device}.json`;
download(data, filename, "application/json");
},
},
};
</script>

View File

@@ -0,0 +1,51 @@
<template>
<modal name="information" adaptive>
<ul class="info-list">
<li>
<GithubIcon size="1x" aria-hidden="true" role="img" />
<a href="https://github.com/owntracks/frontend">owntracks/frontend</a>
({{ frontendVersion }})
</li>
<li>
<GithubIcon size="1x" aria-hidden="true" role="img" />
<a href="https://github.com/owntracks/recorder">owntracks/recorder</a>
({{ recorderVersion || "Loading version..." }})
</li>
<li>
<GlobeIcon size="1x" aria-hidden="true" role="img" />
<a href="https://owntracks.org">
{{ $t("OwnTracks website") }}
</a>
</li>
<li>
<BookIcon size="1x" aria-hidden="true" role="img" />
<a href="https://owntracks.org/booklet/">
{{ $t("OwnTracks documentation") }}
</a>
</li>
<li>
<TwitterIcon size="1x" aria-hidden="true" role="img" />
<a href="https://twitter.com/OwnTracks">
{{ $t("OwnTracks on Twitter") }}
</a>
</li>
</ul>
</modal>
</template>
<script>
import { mapState } from "vuex";
import {
BookIcon,
GithubIcon,
GlobeIcon,
TwitterIcon,
} from "vue-feather-icons";
export default {
components: { BookIcon, GithubIcon, GlobeIcon, TwitterIcon },
computed: {
...mapState(["frontendVersion", "recorderVersion"]),
},
};
</script>

View File

@@ -0,0 +1,32 @@
<template>
<modal name="loading" :click-to-close="false" adaptive>
<LoaderIcon class="loader" size="1.5x" />
{{ $t("Loading data, please wait...") }}
</modal>
</template>
<style scoped>
.loader {
animation: spinning 2s linear infinite;
margin-right: 5px;
}
@keyframes spinning {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<script>
import { LoaderIcon } from "vue-feather-icons";
export default {
components: {
LoaderIcon,
},
};
</script>

86
src/config.js Normal file
View File

@@ -0,0 +1,86 @@
import deepmerge from "deepmerge";
const endDateTime = new Date();
endDateTime.setHours(23, 59, 59, 0);
const startDateTime = new Date(endDateTime);
startDateTime.setMonth(startDateTime.getMonth() - 1);
startDateTime.setHours(0, 0, 0, 0);
const DEFAULT_CONFIG = {
api: {
baseUrl: `${window.location.protocol}//${window.location.host}`,
fetchOptions: {},
},
endDateTime,
filters: {
minAccuracy: null,
},
ignorePingLocation: true,
locale: "en",
map: {
attribution:
'&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
circle: {
color: null,
fillColor: null,
fillOpacity: 0.2,
},
circleMarker: {
color: null,
fillColor: "#fff",
fillOpacity: 1,
radius: 4,
},
controls: {
scale: {
display: true,
imperial: true,
maxWidth: 200,
metric: true,
position: "bottomleft",
},
zoom: {
display: true,
position: "topleft",
},
},
heatmap: {
blur: 15,
gradient: null,
max: 20,
radius: 25,
},
layers: {
heatmap: false,
last: true,
line: true,
points: false,
},
maxNativeZoom: 19,
maxPointDistance: null,
maxZoom: 21,
polyline: {
color: null,
fillColor: "transparent",
},
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
},
onLocationChange: {
fitView: false,
reloadHistory: false,
},
primaryColor: "#3f51b5",
selectedDevice: null,
selectedUser: null,
showDistanceTravelled: true,
startDateTime,
verbose: false,
};
// Use deepmerge to combine the default and user-defined configuration.
// This enables the user to use a fairly small config object which only
// needs to contain actual changes, not all default values - and these
// stay up-to-date automatically.
// There might not be a user-defined config, default to an empty object.
export default deepmerge(DEFAULT_CONFIG, (window.owntracks || {}).config || {});

8
src/constants.js Normal file
View File

@@ -0,0 +1,8 @@
// date and time format as expected by the OwnTracks recorder,
// using moment.js formatting tokens.
// https://momentjs.com/docs/#/displaying/format/
export const DATE_TIME_FORMAT = "YYYY-MM-DDTHH:mm:ss";
// https://en.wikipedia.org/wiki/Earth_radius
// Used to calculate the distance between two coordinates.
export const EARTH_RADIUS_IN_KM = 6371;

23
src/i18n.js Normal file
View File

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

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

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

34
src/locales/de.json Normal file
View File

@@ -0,0 +1,34 @@
{
"Automatically center the map view and zoom in to relevant data": "Kartenansicht automatisch zentrieren und zu relevanten Daten hereinzoomen",
"Fit view": "Ansicht anpassen",
"Layer settings": "Ebeneneinstellungen",
"Show/hide layers": "Ebenen ein-/ausblenden",
"Now": "Jetzt",
"Select start date": "Startdatum auswählen",
"to": "bis",
"Select end date": "Enddatum auswählen",
"Select user": "Benutzer auswählen",
"Show all": "Alle anzeigen",
"Select device": "Gerät auswählen",
"Distance travelled": "Gereiste Entfernung",
"Download raw data": "Rohdaten herunterladen",
"Information": "Information",
"Show last known locations": "Zeige letzte bekannte Standorte",
"Show location history (line)": "Zeige Standortverlauf (Linie)",
"Show location history (points)": "Zeige Standortverlauf (Punkte)",
"Show location heatmap": "Zeige Standort-Heatmap",
"Minify JSON": "JSON minimieren",
"Copy to clipboard": "In die Zwischenablage kopieren",
"Download": "Herunterladen",
"OwnTracks website": "OwnTracks Webseite",
"OwnTracks documentation": "OwnTracks Dokumentation",
"OwnTracks on Twitter": "OwnTracks auf Twitter",
"Loading data, please wait...": "Daten werden geladen, bitte warten...",
"Image of {deviceName}": "Bild von {deviceName}",
"Timestamp": "Zeitstempel",
"Location": "Standort",
"Address": "Adresse",
"Battery": "Akku",
"Speed": "Geschwindigkeit",
"Regions:": "Regionen:"
}

34
src/locales/en.json Normal file
View File

@@ -0,0 +1,34 @@
{
"Automatically center the map view and zoom in to relevant data": "Automatically center the map view and zoom in to relevant data",
"Fit view": "Fit view",
"Layer settings": "Layer settings",
"Show/hide layers": "Show/hide layers",
"Now": "Now",
"Select start date": "Select start date",
"to": "to",
"Select end date": "Select end date",
"Select user": "Select user",
"Show all": "Show all",
"Select device": "Select device",
"Distance travelled": "Distance travelled",
"Download raw data": "Download raw data",
"Information": "Information",
"Show last known locations": "Show last known locations",
"Show location history (line)": "Show location history (line)",
"Show location history (points)": "Show location history (points)",
"Show location heatmap": "Show location heatmap",
"Minify JSON": "Minify JSON",
"Copy to clipboard": "Copy to clipboard",
"Download": "Download",
"OwnTracks website": "OwnTracks website",
"OwnTracks documentation": "OwnTracks documentation",
"OwnTracks on Twitter": "OwnTracks on Twitter",
"Loading data, please wait...": "Loading data, please wait...",
"Image of {deviceName}": "Image of {deviceName}",
"Timestamp": "Timestamp",
"Location": "Location",
"Address": "Address",
"Battery": "Battery",
"Speed": "Speed",
"Regions:": "Regions:"
}

34
src/locales/es.json Normal file
View File

@@ -0,0 +1,34 @@
{
"Automatically center the map view and zoom in to relevant data": "Centrar automáticamente el zoom y la vista del mapa a los datos",
"Fit view": "Ajustar vista",
"Layer settings": "Configuración de capas",
"Show/hide layers": "Mostrar/ocultar capas",
"Now": "Ahora",
"Select start date": "Seleccionar fecha inicio",
"to": "hasta",
"Select end date": "Seleccionar fecha fin",
"Select user": "Seleccionar usuario",
"Show all": "Mostrar todos",
"Select device": "Seleccionar dispositivo",
"Distance travelled": "Distancia recorrida",
"Download raw data": "Descargar datos en crudo",
"Information": "Información",
"Show last known locations": "Mostrar última ubicación conocida",
"Show location history (line)": "Mostrar historial (línea)",
"Show location history (points)": "Mostrar historial (puntos)",
"Show location heatmap": "Mostra mapa de calor",
"Minify JSON": "Reducir JSON",
"Copy to clipboard": "Copiar al portapapeles",
"Download": "Descarga",
"OwnTracks website": "OwnTracks - Sitio web",
"OwnTracks documentation": "OwnTracks - documentación",
"OwnTracks on Twitter": "OwnTracks en Twitter",
"Loading data, please wait...": "Cargando datos, por favor, espera...",
"Image of {deviceName}": "Imágen de {deviceName}",
"Timestamp": "Fecha / Hora",
"Location": "Ubicación",
"Address": "Dirección",
"Battery": "Bateria",
"Speed": "Velocidad",
"Regions:": "Regiones:"
}

53
src/logging.js Normal file
View File

@@ -0,0 +1,53 @@
import config from "@/config";
export const logLevels = {
INFO: "INFO",
WARNING: "WARNING",
ERROR: "ERROR",
};
/* eslint-disable no-console */
const logFunctions = {
[logLevels.INFO]: console.info,
[logLevels.WARNING]: console.warn,
[logLevels.ERROR]: console.error,
};
/* eslint-enable no-console */
const logColors = {
[logLevels.INFO]: "#0d66ba",
[logLevels.WARNING]: "#cf8429",
[logLevels.ERROR]: "#ad1515",
};
/**
* Log a message to the browser's console.
*
* Convenience wrapper for `console.{info,warn,error}` doing some formatting
* and taking the `verbose` config option into account.
*
* @param {String} label Log message label, useful for filtering
* @param {String|LogMessageFunction} message Log message
* @param {String} [level] Log level, use `logLevels` constants
*/
export const log = (label, message, level = logLevels.INFO) => {
if (!Object.keys(logLevels).includes(level)) {
log("WARNING", `invalid log level: ${level}`, logLevels.WARNING);
return;
}
if (level !== logLevels.ERROR && !config.verbose) {
return;
}
const css = `
background: ${logColors[level]};
border-radius: 5px;
color: #fff;
padding: 3px;
`;
const logFunc = logFunctions[level];
logFunc(
`%c${label}`,
css,
typeof message === "function" ? message() : message
);
};

33
src/main.js Normal file
View File

@@ -0,0 +1,33 @@
import Vue from "vue";
import VueModal from "vue-js-modal";
import VueOutsideEvents from "vue-outside-events";
import VueMq from "vue-mq";
import App from "@/App.vue";
import config from "@/config";
import { log } from "@/logging";
import i18n from "@/i18n";
import router from "@/router";
import store from "@/store";
Vue.use(VueModal);
Vue.use(VueOutsideEvents);
Vue.use(VueMq, {
breakpoints: {
sm: 1300,
lg: Infinity,
},
});
Vue.config.productionTip = false;
log("CONFIG", config);
Vue.prototype.$config = config;
new Vue({
i18n,
router,
store,
render: (h) => h(App),
}).$mount("#app");

17
src/router.js Normal file
View File

@@ -0,0 +1,17 @@
import Vue from "vue";
import Router from "vue-router";
import Map from "./views/Map.vue";
Vue.use(Router);
export default new Router({
mode: "history",
base: process.env.BASE_URL,
routes: [
{
path: "/",
name: "map",
component: Map,
},
],
});

261
src/store/actions.js Normal file
View File

@@ -0,0 +1,261 @@
import * as types from "@/store/mutation-types";
import * as api from "@/api";
import config from "@/config";
import { log } from "@/logging";
import {
distanceBetweenCoordinates,
isIsoDateTime,
getLocationHistoryCount,
} from "@/util";
/**
* Populate the state from URL query parameters.
*
* @param {QueryParams} query URL query parameters
*/
const populateStateFromQuery = ({ state, commit }, query) => {
if (query.lat && !isNaN(parseFloat(query.lat))) {
commit(types.SET_MAP_CENTER, {
lat: query.lat,
lng: parseFloat(state.map.center.lng),
});
}
if (query.lng && !isNaN(parseFloat(query.lng))) {
commit(types.SET_MAP_CENTER, {
lat: parseFloat(state.map.center.lat),
lng: query.lng,
});
}
if (query.zoom && !isNaN(parseInt(query.zoom))) {
commit(types.SET_MAP_ZOOM, parseInt(query.zoom));
}
if (query.start && isIsoDateTime(query.start)) {
commit(types.SET_START_DATE_TIME, query.start);
}
if (query.end && isIsoDateTime(query.end)) {
commit(types.SET_END_DATE_TIME, query.end);
}
if (query.user) {
commit(types.SET_SELECTED_USER, query.user);
}
if (query.device) {
commit(types.SET_SELECTED_DEVICE, query.device);
}
if (query.layers) {
const activeLayers = query.layers.split(",");
Object.keys(state.map.layers).forEach((layer) => {
const visibility = activeLayers.includes(layer);
if (state.map.layers[layer] !== visibility) {
commit(types.SET_MAP_LAYER_VISIBILITY, { layer, visibility });
}
});
}
};
/**
* Trigger loading of all required data: users, devices, last locations,
* location history, version and initiate WebSocket connection.
*/
const loadData = async ({ dispatch }) => {
await dispatch("getUsers");
await dispatch("getDevices");
await dispatch("getLastLocations");
await dispatch("getLocationHistory");
await dispatch("getRecorderVersion");
await dispatch("connectWebsocket");
};
/**
* Reload last locations and location history. Will be called when
* start date, end date, selected user or selected device changes.
*/
const reloadData = async ({ dispatch }) => {
await dispatch("getLastLocations");
await dispatch("getLocationHistory");
};
/**
* Connect to WebSocket to receive live location updates. When an update is
* received, reload last locations and location history depending on config.
*/
const connectWebsocket = async ({ dispatch }) => {
api.connectWebsocket(async () => {
// TODO: keep cards from HTTP API response in the Vuex store so we
// can use the data from the WebSocket location update (which does
// not contain card information) and don't have to poll the API.
await dispatch("getLastLocations");
if (config.onLocationChange.reloadHistory) {
await dispatch("getLocationHistory");
}
});
};
/**
* Load user names.
*/
const getUsers = async ({ commit }) => {
commit(types.SET_USERS, await api.getUsers());
};
/**
* Load devices names of all users.
*/
const getDevices = async ({ commit, state }) => {
commit(types.SET_DEVICES, await api.getDevices(state.users));
};
/**
* Load last location of the selected user/device.
*/
const getLastLocations = async ({ commit, state }) => {
let lastLocations = await api.getLastLocations(
state.selectedUser,
state.selectedDevice
);
if (config.ignorePingLocation) {
// Remove ping/ping from the owntracks/recorder Docker image
// https://github.com/owntracks/frontend/issues/12
lastLocations = lastLocations.filter(
(l) => !(l.username === "ping" && l.device === "ping")
);
}
commit(types.SET_LAST_LOCATIONS, lastLocations);
};
const _getDistanceTravelled = (locationHistory) => {
const start = Date.now();
let distanceTravelled = 0;
Object.keys(locationHistory).forEach((user) => {
Object.keys(locationHistory[user]).forEach((device) => {
let lastLatLng = null;
locationHistory[user][device].forEach((location) => {
if (
config.filters.minAccuracy !== null &&
location.acc > config.filters.minAccuracy
)
return;
const latLng = L.latLng(location.lat, location.lon);
if (lastLatLng !== null) {
const distance = distanceBetweenCoordinates(lastLatLng, latLng);
if (
typeof config.map.maxPointDistance === "number" &&
config.map.maxPointDistance > 0
) {
if (distance <= config.map.maxPointDistance) {
// Part of the current group, add calculated distance to total
distanceTravelled += distance;
}
} else {
// If grouping is disabled always add calculated distance to total
distanceTravelled += distance;
}
}
lastLatLng = latLng;
});
});
});
const end = Date.now();
log("DISTANCE", () => {
const locationHistoryCount = getLocationHistoryCount(locationHistory);
const duration = (end - start) / 1000;
return (
`[_getDistanceTravelled] Took ${duration} seconds to ` +
`calculate distance of ${locationHistoryCount} locations`
);
});
return distanceTravelled;
};
/**
* Load location history of all devices, in the selected date range.
*/
const getLocationHistory = async ({ commit, state }) => {
commit(types.SET_IS_LOADING, true);
let devices;
if (state.selectedUser) {
if (state.selectedDevice) {
devices = { [state.selectedUser]: [state.selectedDevice] };
} else {
devices = { [state.selectedUser]: state.devices[state.selectedUser] };
}
} else {
devices = state.devices;
}
const locationHistory = await api.getLocationHistory(
devices,
state.startDateTime,
state.endDateTime
);
commit(types.SET_IS_LOADING, false);
commit(types.SET_LOCATION_HISTORY, locationHistory);
if (config.showDistanceTravelled) {
commit(
types.SET_DISTANCE_TRAVELLED,
_getDistanceTravelled(locationHistory)
);
}
};
/**
* Load the OwnTracks recorder version.
*/
const getRecorderVersion = async ({ commit }) => {
commit(types.SET_RECORDER_VERSION, await api.getVersion());
};
/**
* Set the selected user and reload the location history.
*
* @param {User} user Name of the new selected user
*/
const setSelectedUser = async ({ commit, dispatch }, user) => {
commit(types.SET_SELECTED_DEVICE, null);
commit(types.SET_SELECTED_USER, user);
await dispatch("reloadData");
};
/**
* Set the selected device and reload the location history.
*
* @param {Device} device Name of the new selected device
*/
const setSelectedDevice = async ({ commit, dispatch }, device) => {
commit(types.SET_SELECTED_DEVICE, device);
await dispatch("reloadData");
};
/**
* Set the start date and time for loading data and reload the location history.
*
* @param {String} startDateTime Start date and time in UTC for loading data
*/
const setStartDateTime = async ({ commit, dispatch }, startDateTime) => {
commit(types.SET_START_DATE_TIME, startDateTime);
await dispatch("reloadData");
};
/**
* Set the end date and time for loading data and reload the location history.
*
* @param {String} endDateTime End date and time in UTC for loading data
*/
const setEndDateTime = async ({ commit, dispatch }, endDateTime) => {
commit(types.SET_END_DATE_TIME, endDateTime);
await dispatch("reloadData");
};
export default {
populateStateFromQuery,
loadData,
reloadData,
connectWebsocket,
getUsers,
getDevices,
getLastLocations,
getLocationHistory,
getRecorderVersion,
setSelectedUser,
setSelectedDevice,
setStartDateTime,
setEndDateTime,
};

99
src/store/getters.js Normal file
View File

@@ -0,0 +1,99 @@
import L from "leaflet";
import config from "@/config";
import { distanceBetweenCoordinates } from "@/util";
/**
* Apply filters to the selected users' and devices' location histories.
*
* @param {State} state
* @param {LocationHistory} state.locationHistory
* Location history of selected users and devices
* @returns {LocationHistory} Filtered location history
*/
const filteredLocationHistory = (state) => {
const locationHistory = {};
Object.keys(state.locationHistory).forEach((user) => {
locationHistory[user] = {};
Object.keys(state.locationHistory[user]).forEach((device) => {
locationHistory[user][device] = [];
state.locationHistory[user][device].forEach((location) => {
if (
config.filters.minAccuracy !== null &&
location.acc > config.filters.minAccuracy
)
return;
locationHistory[user][device].push(location);
});
});
});
return locationHistory;
};
/**
* From the selected users' and devices' location histories, create an
* array of all coordinates.
*
* @param {State} state
* @returns {L.LatLng[]} All coordinates
*/
const filteredLocationHistoryLatLngs = (state) => {
const latLngs = [];
const locationHistory = filteredLocationHistory(state);
Object.keys(locationHistory).forEach((user) => {
Object.keys(locationHistory[user]).forEach((device) => {
locationHistory[user][device].forEach((location) => {
latLngs.push(L.latLng(location.lat, location.lon));
});
});
});
return latLngs;
};
/**
* From the selected users' and devices' location histories, create an
* array of coordinate groups where the distance between two subsequent
* coordinates does not exceed `config.map.maxPointDistance`.
*
* @param {State} state
* @returns {L.LatLng[][]} Groups of coherent coordinates
*/
const filteredLocationHistoryLatLngGroups = (state) => {
const groups = [];
const locationHistory = filteredLocationHistory(state);
Object.keys(locationHistory).forEach((user) => {
Object.keys(locationHistory[user]).forEach((device) => {
let latLngs = [];
locationHistory[user][device].forEach((location) => {
const latLng = L.latLng(location.lat, location.lon);
// Skip if group splitting is disabled or this is the first
// coordinate in the current group
if (
typeof config.map.maxPointDistance === "number" &&
config.map.maxPointDistance > 0 &&
latLngs.length > 0
) {
const lastLatLng = latLngs.slice(-1)[0];
if (
distanceBetweenCoordinates(lastLatLng, latLng) >
config.map.maxPointDistance
) {
// Distance is too far, start new group of coordinate
groups.push(latLngs);
latLngs = [];
}
}
// Add coordinate to current active group
latLngs.push(latLng);
});
groups.push(latLngs);
});
});
return groups;
};
export default {
filteredLocationHistory,
filteredLocationHistoryLatLngs,
filteredLocationHistoryLatLngGroups,
};

38
src/store/index.js Normal file
View File

@@ -0,0 +1,38 @@
import Vue from "vue";
import Vuex from "vuex";
import getters from "@/store/getters";
import mutations from "@/store/mutations";
import actions from "@/store/actions";
import config from "@/config";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
isLoading: false,
frontendVersion: process.env.PACKAGE_VERSION,
recorderVersion: "",
users: [],
devices: {},
lastLocations: [],
locationHistory: {},
selectedUser: config.selectedUser,
selectedDevice: config.selectedUser !== null ? config.selectedDevice : null,
// Convert to UTC and get rid of milliseconds
startDateTime: config.startDateTime.toISOString().slice(0, 19),
endDateTime: config.endDateTime.toISOString().slice(0, 19),
map: {
center: {
lat: 0,
lng: 0,
},
zoom: 19,
layers: config.map.layers,
},
distanceTravelled: null,
},
getters,
mutations,
actions,
});

View File

@@ -0,0 +1,14 @@
export const SET_IS_LOADING = "SET_IS_LOADING";
export const SET_RECORDER_VERSION = "SET_RECORDER_VERSION";
export const SET_USERS = "SET_USERS";
export const SET_DEVICES = "SET_DEVICES";
export const SET_LAST_LOCATIONS = "SET_LAST_LOCATIONS";
export const SET_LOCATION_HISTORY = "SET_LOCATION_HISTORY";
export const SET_SELECTED_USER = "SET_SELECTED_USER";
export const SET_SELECTED_DEVICE = "SET_SELECTED_DEVICE";
export const SET_START_DATE_TIME = "SET_START_DATE_TIME";
export const SET_END_DATE_TIME = "SET_END_DATE_TIME";
export const SET_MAP_CENTER = "SET_MAP_CENTER";
export const SET_MAP_ZOOM = "SET_MAP_ZOOM";
export const SET_MAP_LAYER_VISIBILITY = "SET_MAP_LAYER_VISIBILITY";
export const SET_DISTANCE_TRAVELLED = "SET_DISTANCE_TRAVELLED";

46
src/store/mutations.js Normal file
View File

@@ -0,0 +1,46 @@
import * as types from "@/store/mutation-types";
export default {
[types.SET_IS_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[types.SET_RECORDER_VERSION](state, version) {
state.recorderVersion = version;
},
[types.SET_USERS](state, users) {
state.users = users;
},
[types.SET_DEVICES](state, devices) {
state.devices = devices;
},
[types.SET_LAST_LOCATIONS](state, lastLocations) {
state.lastLocations = lastLocations;
},
[types.SET_LOCATION_HISTORY](state, locationHistory) {
state.locationHistory = locationHistory;
},
[types.SET_SELECTED_USER](state, selectedUser) {
state.selectedUser = selectedUser;
},
[types.SET_SELECTED_DEVICE](state, selectedDevice) {
state.selectedDevice = selectedDevice;
},
[types.SET_START_DATE_TIME](state, startDateTime) {
state.startDateTime = startDateTime;
},
[types.SET_END_DATE_TIME](state, endDateTime) {
state.endDateTime = endDateTime;
},
[types.SET_MAP_CENTER](state, center) {
state.map.center = center;
},
[types.SET_MAP_ZOOM](state, zoom) {
state.map.zoom = zoom;
},
[types.SET_MAP_LAYER_VISIBILITY](state, { layer, visibility }) {
state.map.layers[layer] = visibility;
},
[types.SET_DISTANCE_TRAVELLED](state, distanceTravelled) {
state.distanceTravelled = distanceTravelled;
},
};

354
src/styles/_base.scss Normal file
View File

@@ -0,0 +1,354 @@
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
:root {
--color-text: #333;
--color-background: #fff;
--color-primary: #3f51b5;
--color-primary-text: #fff;
--color-separator: #ddd;
--drop-shadow: drop-shadow(0 10px 10px rgb(0, 0, 0, 0.2));
--dropdown-arrow: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2225%22%20height%3D%2210%22%3E%3Cpath%20fill%3D%22%23333%22%20fill-opacity%3D%221%22%20stroke%3D%22none%22%20d%3D%22M0%2C0%20L0%2C0%20L1%2C0%20L1%2C6%20L7%2C6%20L7%2C7%20L0%2C7%20z%22%20transform%3D%22rotate(-45%205%200)%22%20%2F%3E%3C%2Fsvg%3E");
--pin-width: 32px;
}
html, body {
height: 100%;
}
body {
font-family: "Noto Sans", sans-serif;
font-size: 14px;
color: var(--color-text);
}
a {
color: var(--color-primary);
}
ul {
list-style: inside;
}
input[type="checkbox"] {
appearance: none;
border: 0; // Remove the unchecked checkbox outline in Safari on iOS
border-radius: 4px; // Round the focus box-shadow
cursor: pointer;
margin-right: 3px;
position: relative;
vertical-align: top;
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.2);
}
&::before {
border: 2px solid var(--color-primary);
border-radius: 4px;
content: "";
display: block;
height: 16px;
width: 16px;
}
&:checked::before {
background: var(--color-primary);
}
&:checked::after {
border-bottom: 2px solid var(--color-primary-text);
border-right: 2px solid var(--color-primary-text);
content: "";
display: inline-block;
height: 10px;
left: 7px;
position: absolute;
top: 3px;
transform: rotate(45deg);
width: 4px;
}
& + label {
cursor: pointer;
}
}
pre {
background: #000;
border-radius: 3px;
color: #ddd;
display: block;
font-family: Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console",
"Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono",
"Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace;
overflow-x: auto;
code {
display: block;
margin: 20px;
}
}
#app {
display: flex;
min-height: 100%;
flex-direction: column;
// Only select immediate child as the datepicker contains a <header> as well
> header {
display: flex;
padding: 20px;
white-space: nowrap;
color: var(--color-primary-text);
background: var(--color-primary);
&.header-sm {
padding: 10px;
.header-item:not(.nav-sm) .nav-item:not(:first-child) {
margin-left: 10px;
}
}
.header-item {
display: flex;
align-items: center;
&:not(:first-child) {
margin-left: 20px;
}
&-grow {
flex: 1;
}
&-right {
margin-left: auto !important;
}
.feather {
font-size: 20px;
}
.nav-item {
&:not(:first-child) {
margin-left: 20px;
}
> span {
margin: 0 5px;
}
.feather {
margin-right: 10px;
}
.button-icon .feather {
margin: 0;
}
}
&.nav-sm {
background: var(--color-primary);
border-top: 1px solid rgba(0, 0, 0, 0.2);
bottom: 0;
display: block;
left: 0;
margin: 0;
overflow-x: auto;
padding: 20px;
position: absolute;
right: 0;
top: 56px;
z-index: 1;
.nav-item {
align-items: center;
display: flex;
flex-wrap: wrap;
margin-left: auto;
margin-right: auto;
max-width: 400px;
&:not(:first-child) {
margin-top: 20px;
}
> .button,
> .dropdown,
> .date-time-picker {
flex: 1;
}
> .dropdown .dropdown-button,
> .date-time-picker .dropdown-button {
width: 100%;
}
> .date-time-picker {
margin-left: 0;
margin-right: 0;
}
> span {
flex-basis: 100%;
margin: 0;
}
// THIS IS TERRIBLE (but it works for now)
> :not(:nth-child(1)):not(:nth-child(2)) {
display: block;
margin-left: 30px;
margin-top: 5px;
}
}
}
}
}
main {
flex: 1;
position: relative;
}
ul.info-list {
list-style: none;
li {
// https://stackoverflow.com/a/17158366/5952681
margin-left: 25px;
text-indent: -25px;
.feather {
font-size: 16px;
margin-right: 8px;
}
a {
font-weight: bold;
text-decoration: none;
}
& + li {
margin-top: 15px;
}
}
}
}
.button {
cursor: pointer;
color: var(--color-text);
background: var(--color-background);
border: 0;
border-radius: 18px;
overflow: hidden;
padding: 8px 16px;
text-overflow: ellipsis;
transition: box-shadow 0.2s;
white-space: nowrap;
&:focus {
outline: none;
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.2);
}
&:focus::-moz-focus-inner {
border-color: transparent;
}
&.button-primary {
color: var(--color-primary-text);
background: var(--color-primary);
}
&.button-outline {
border: 1px solid var(--color-background);
color: var(--color-primary-text);
background: transparent;
&.button-primary {
border-color: var(--color-primary);
color: var(--color-text);
}
}
&.button-flat {
color: var(--color-primary-text);
background: transparent;
&.button-primary {
color: var(--color-text);
}
}
&.button-outline,
&.button-flat {
transition: background-color 0.2s, box-shadow 0.2s;
&:hover,
&:focus {
background: rgba(0, 0, 0, 0.2);
}
}
&.button-icon {
padding: 8px;
}
}
.dropdown {
display: inline-block;
position: relative;
}
// Not nested so it works as the button alone
.dropdown-button {
appearance: none;
background-image: var(--dropdown-arrow);
background-repeat: no-repeat;
background-position-x: right;
background-position-y: center;
padding-right: 30px;
}
.dropdown-body {
position: absolute;
margin-top: 12px;
padding: 8px 0;
border-radius: 3px;
color: var(--color-text);
background: var(--color-background);
filter: var(--drop-shadow);
z-index: 1;
&::before {
content: "";
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid var(--color-background);
position: absolute;
top: -20px;
left: 20px;
}
label {
cursor: pointer;
display: block;
padding: 8px 15px;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
}
}
.feather {
vertical-align: middle;
}

View File

@@ -0,0 +1,10 @@
.date-time-picker {
display: inline-block;
width: auto;
.datepicker {
box-shadow: none !important;
filter: var(--drop-shadow);
margin-top: 5px;
}
}

84
src/styles/_map.scss Normal file
View File

@@ -0,0 +1,84 @@
.leaflet-container {
position: absolute;
z-index: 0;
.leaflet-popup {
filter: var(--drop-shadow);
&--for-pin {
margin-bottom: calc(var(--pin-width) * 1.5 + 20px);
}
.leaflet-popup-content-wrapper {
border-radius: 3px;
box-shadow: none;
.leaflet-popup-content {
margin: 30px;
}
}
a.leaflet-popup-close-button {
color: inherit;
width: auto;
height: auto;
padding: 10px;
margin-top: 15px;
margin-right: 15px;
border-radius: 100px;
transition: background-color 0.2s;
&:hover,
&:focus {
background: rgba(0, 0, 0, 0.2);
color: inherit;
}
}
}
.leaflet-popup-tip {
box-shadow: none;
}
.leaflet-control-container .leaflet-control-attribution {
background: var(--color-background);
a {
color: var(--color-primary);
}
}
.leaflet-marker-icon {
width: 0 !important;
height: 0 !important;
margin: 0 !important;
.pin {
display: block;
margin-left: calc(-1 * var(--pin-width) / 2);
margin-top: calc(-1 * var(--pin-width) * 1.5);
position: relative;
width: var(--pin-width);
&::before {
background: var(--color-background);
border-radius: 100%;
content: "";
position: absolute;
width: calc(var(--pin-width) / 2);
height: calc(var(--pin-width) / 2);
top: calc(var(--pin-width) / 4);
left: calc(var(--pin-width) / 4);
}
svg {
height: auto;
width: 100%;
path {
fill: var(--color-primary);
}
}
}
}
}

22
src/styles/_modal.scss Normal file
View File

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

4
src/styles/main.scss Normal file
View File

@@ -0,0 +1,4 @@
@import "base";
@import "datepicker";
@import "map";
@import "modal";

118
src/util.js Normal file
View File

@@ -0,0 +1,118 @@
import moment from "moment";
import config from "@/config";
import { DATE_TIME_FORMAT, EARTH_RADIUS_IN_KM } from "@/constants";
/**
* Get a complete URL for any API resource, taking the
* base URL configuration into account.
*
* @param {String} path Path to the API resource
* @returns {URL} Final API URL
*/
export const getApiUrl = (path) => {
const normalizedBaseUrl = config.api.baseUrl.endsWith("/")
? config.api.baseUrl.slice(1)
: config.api.baseUrl;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return new URL(`${normalizedBaseUrl}${normalizedPath}`);
};
/**
* Check if the given string is an ISO 8601 YYYY-MM-DDTHH:MM:SS datetime.
*
* @param {String} s Input value to be tested
* @returns {Boolean} Whether the input matches the expected format
*/
export const isIsoDateTime = (s) => moment(s, DATE_TIME_FORMAT, true).isValid();
/**
* Convert degrees to radians.
*
* @param {Number} degrees Angle in degrees
* @returns {Number} Angle in radians
*/
export const degreesToRadians = (degrees) => (degrees * Math.PI) / 180;
/**
* Calculate the distance between two coordinates. Uses the haversine formula,
* which is not 100% accurate - but that's not the goal here.
*
* https://en.wikipedia.org/wiki/Haversine_formula
*
* @param {Coordinate} c1 First coordinate
* @param {Coordinate} c2 Second coordinate
* @returns {Number} Distance in meters
*/
export const distanceBetweenCoordinates = (c1, c2) => {
const r = EARTH_RADIUS_IN_KM * 1000;
const phi1 = degreesToRadians(c1.lat);
const phi2 = degreesToRadians(c2.lat);
const lambda1 = degreesToRadians(c1.lng);
const lambda2 = degreesToRadians(c2.lng);
const d =
2 *
r *
Math.asin(
Math.sqrt(
Math.sin((phi2 - phi1) / 2) ** 2 +
Math.cos(phi1) *
Math.cos(phi2) *
Math.sin((lambda2 - lambda1) / 2) ** 2
)
);
return d;
};
/**
* Let the user download a string as file.
*
* @param {String} text Content of the file
* @param {String} filename Suggested filename for the browser
* @param {String} [mimeType] Content mime type
*/
export const download = (text, filename, mimeType = "text/plain") => {
const dataUrl = `data:${mimeType},${encodeURIComponent(text)}`;
const element = document.createElement("a");
element.href = dataUrl;
element.download = filename;
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
/**
* Format a distance in meters into a human-readable string with unit.
*
* This only supports m / km for now, but could read a config option and return
* ft / mi.
*
* @param {Number} distance Distance in meters
* @returns {String} Formatted string including unit
*/
export const humanReadableDistance = (distance) => {
let unit = "m";
if (Math.abs(distance) >= 1000) {
distance = distance / 1000;
unit = "km";
}
return `${distance.toLocaleString(config.locale, {
maximumFractionDigits: 1,
})} ${unit}`;
};
/**
* Get the total number of locations from a nested location history.
*
* @param {LocationHistory} locationHistory Location history
* @returns {Number} Total number of locations
*/
export const getLocationHistoryCount = (locationHistory) =>
Object.keys(locationHistory)
.map((user) =>
Object.keys(locationHistory[user])
.map((device) => locationHistory[user][device].length)
.reduce((a, b) => a + b, 0)
)
.reduce((a, b) => a + b, 0);

244
src/views/Map.vue Normal file
View File

@@ -0,0 +1,244 @@
<template>
<LMap
ref="map"
:center="map.center"
:zoom="map.zoom"
:options="{ zoomControl: false }"
@update:center="setMapCenter"
@update:zoom="setMapZoom"
>
<LControlZoom
v-if="controls.zoom.display"
:position="controls.zoom.position"
/>
<LControlScale
v-if="controls.scale.display"
:position="controls.scale.position"
:maxWidth="controls.scale.maxWidth"
:metric="controls.scale.metric"
:imperial="controls.scale.imperial"
/>
<LTileLayer
:url="url"
:attribution="attribution"
:options="{ maxNativeZoom, maxZoom }"
/>
<template v-if="map.layers.last">
<LCircle
v-for="l in lastLocations"
:key="`${l.topic}-circle`"
:lat-lng="[l.lat, l.lon]"
:radius="l.acc"
v-bind="circle"
/>
<LMarker
v-for="l in lastLocations"
:key="`${l.topic}-marker`"
:lat-lng="[l.lat, l.lon]"
:icon="markerIcon"
>
<LDeviceLocationPopup
:user="l.username"
:device="l.device"
:name="l.name"
:face="l.face"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
:regions="l.inregions"
:options="{ className: 'leaflet-popup--for-pin' }"
/>
</LMarker>
</template>
<template v-if="map.layers.line">
<LPolyline
v-for="(group, i) in filteredLocationHistoryLatLngGroups"
:key="i"
:lat-lngs="group"
v-bind="polyline"
/>
</template>
<template v-if="map.layers.points">
<template v-for="(userDevices, user) in filteredLocationHistory">
<template v-for="(deviceLocations, device) in userDevices">
<LCircleMarker
v-for="(l, n) in deviceLocationsWithNameAndFace(
user,
device,
deviceLocations
)"
:key="`${user}-${device}-${n}`"
:lat-lng="[l.lat, l.lon]"
v-bind="circleMarker"
>
<LDeviceLocationPopup
:user="user"
:device="device"
:name="l.name"
:face="l.face"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
:regions="l.inregions"
></LDeviceLocationPopup>
</LCircleMarker>
</template>
</template>
</template>
<template v-if="map.layers.heatmap">
<LHeatmap
v-if="filteredLocationHistoryLatLngs.length"
:lat-lng="filteredLocationHistoryLatLngs"
:max="heatmap.max"
:radius="heatmap.radius"
:blur="heatmap.blur"
:gradient="heatmap.gradient"
/>
</template>
</LMap>
</template>
<script>
import { mapGetters, mapState, mapMutations } from "vuex";
import L from "leaflet";
import {
LMap,
LTileLayer,
LControlScale,
LControlZoom,
LMarker,
LCircleMarker,
LCircle,
LPolyline,
} from "vue2-leaflet";
import "leaflet/dist/leaflet.css";
import * as types from "@/store/mutation-types";
import LCustomMarker from "@/components/LCustomMarker";
import LHeatmap from "@/components/LHeatmap";
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup";
export default {
components: {
LMap,
LTileLayer,
LControlScale,
LControlZoom,
LMarker,
LCircleMarker,
LCircle,
LPolyline,
LDeviceLocationPopup,
LHeatmap,
},
data() {
return {
attribution: this.$config.map.attribution,
center: this.$store.state.map.center,
controls: this.$config.map.controls,
heatmap: this.$config.map.heatmap,
markerIcon: LCustomMarker,
maxZoom: this.$config.map.maxZoom,
maxNativeZoom: this.$config.map.maxNativeZoom,
url: this.$config.map.url,
zoom: this.$store.state.map.zoom,
circle: {
...this.$config.map.circle,
color: this.$config.map.circle.color || this.$config.primaryColor,
fillColor:
this.$config.map.circle.fillColor || this.$config.primaryColor,
},
circleMarker: {
...this.$config.map.circleMarker,
color: this.$config.map.circleMarker.color || this.$config.primaryColor,
},
polyline: {
...this.$config.map.polyline,
color: this.$config.map.polyline.color || this.$config.primaryColor,
},
};
},
mounted() {
this.$root.$on("fitView", () => {
this.fitView();
});
},
computed: {
...mapGetters([
"filteredLocationHistory",
"filteredLocationHistoryLatLngs",
"filteredLocationHistoryLatLngGroups",
]),
...mapState(["lastLocations", "map"]),
},
methods: {
...mapMutations({
setMapCenter: types.SET_MAP_CENTER,
setMapZoom: types.SET_MAP_ZOOM,
}),
/**
* Fit all objects on the map into view.
*/
fitView() {
if (
(this.map.layers.line ||
this.map.layers.points ||
this.map.layers.heatmap) &&
this.filteredLocationHistoryLatLngs.length > 0
) {
this.$refs.map.mapObject.fitBounds(
new L.LatLngBounds(this.filteredLocationHistoryLatLngs)
);
} else if (this.map.layers.last && this.lastLocations.length > 0) {
const locations = this.lastLocations.map((l) => L.latLng(l.lat, l.lon));
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(locations), {
maxZoom: this.maxNativeZoom,
});
}
},
/**
* Find a the last location object for a user/device combination from the
* local cache and backfill name and face attributes to each item from the
* passed array of location objects.
*
* @param {User} user Username
* @param {Device} device Device name
* @param {OTLocation[]} deviceLocations Device name
* @returns {OTLocation[]} Updated locations
*/
deviceLocationsWithNameAndFace(user, device, deviceLocations) {
const lastLocation = this.lastLocations.find(
(l) => l.username === user && l.device === device
);
if (!lastLocation) {
return deviceLocations;
}
return deviceLocations.map((l) => ({
...l,
name: lastLocation.name,
face: lastLocation.face,
}));
},
},
watch: {
lastLocations() {
if (this.$config.onLocationChange.fitView) {
this.fitView();
}
},
filteredLocationHistory() {
this.fitView();
},
},
};
</script>

View File

@@ -1,80 +0,0 @@
(() => {
const props = {
user: {
type: String,
default: '',
},
device: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
face: {
type: String,
default: null,
},
timestamp: {
type: Number,
default: 0,
},
lat: {
type: Number,
default: 0,
},
lon: {
type: Number,
default: 0,
},
alt: {
type: Number,
default: 0,
},
address: {
type: String,
default: null,
},
battery: {
type: Number,
default: null,
},
speed: {
type: Number,
default: null,
},
};
const { LPopup } = Vue2Leaflet;
Vue.component('location-popup', {
template: `
<l-popup>
<img v-if="face" class="location-popup-face" :src="faceImageDataURI">
<b v-if="name">{{ name }}</b>
<b v-else>{{ user }}/{{ device }}</b>
<div class="location-popup-detail">
<span class="mdi mdi-16px mdi-calendar-clock"></span> {{ new Date(timestamp * 1000).toLocaleString() }}
</div>
<div class="location-popup-detail">
<span class="mdi mdi-16px mdi-crosshairs-gps"></span> {{ lat }}, {{ lon }}, {{ alt }}m
</div class="location-popup-detail">
<div v-if="address" class="location-popup-detail">
<span class="mdi mdi-16px mdi-map-marker"></span> {{ address }}
</div>
<div v-if="typeof battery === 'number'" class="location-popup-detail">
<span class="mdi mdi-16px mdi-battery"></span> {{ battery }} %
</div>
<div v-if="typeof battery === 'number'" class="location-popup-detail">
<span class="mdi mdi-16px mdi-speedometer"></span> {{ speed }} km/h
</div>
</l-popup>
`,
components: { LPopup },
props,
computed: {
faceImageDataURI() {
return `data:image/png;base64,${this.face}`;
},
},
});
})();

View File

@@ -1,18 +0,0 @@
Vue.component('modal', {
template: `
<div class="modal" v-show="visible" @click.self="$emit('close')">
<div class="modal-container">
<button class="modal-close-button" title="Close" @click="$emit('close')">
&times;
</button>
<slot></slot>
</div>
</div>
`,
props: {
visible: {
type: Boolean,
default: false,
},
},
});

View File

@@ -1,152 +0,0 @@
(() => {
const capitalizeFirstLetter = (string) => {
return string.charAt(0).toUpperCase() + string.slice(1);
}
const propsBinder = (vueElement, leafletElement, props) => {
for (const key in props) {
const setMethodName = 'set' + capitalizeFirstLetter(key);
const deepValue = (props[key].type === Object) ||
(props[key].type === Array) ||
(Array.isArray(props[key].type));
if (props[key].custom && vueElement[setMethodName]) {
vueElement.$watch(key, (newVal, oldVal) => {
vueElement[setMethodName](newVal, oldVal);
}, {
deep: deepValue
});
} else if (setMethodName === 'setOptions') {
vueElement.$watch(key, (newVal, oldVal) => {
L.setOptions(leafletElement, newVal);
}, {
deep: deepValue
});
} else if (leafletElement[setMethodName]) {
vueElement.$watch(key, (newVal, oldVal) => {
leafletElement[setMethodName](newVal);
}, {
deep: deepValue
});
}
}
};
const { findRealParent, L } = Vue2Leaflet;
const props = {
latLng: {
type: Array,
custom: false,
default: () => []
},
minOpacity: {
type: Number,
custom: true,
default: 0.05
},
maxZoom: {
type: Number,
custom: true,
default: 18
},
radius: {
type: Number,
custom: true,
default: 25
},
blur: {
type: Number,
custom: true,
default: 15
},
max: {
type: Number,
custom: true,
default: 1.0
},
gradient: {
type: Object,
custom: true,
default: () => ({
0.4: 'blue',
0.6: 'cyan',
0.7: 'lime',
0.8: 'yellow',
1.0: 'red'
})
},
visible: {
type: Boolean,
custom: true,
default: true
}
};
Vue.component('l-heatmap', {
props,
template: '<div></div>',
mounted() {
const options = {};
if (this.minOpacity) {
options.minOpacity = this.minOpacity;
}
if (this.maxZoom) {
options.maxZoom = this.maxZoom;
}
if (this.radius) {
options.radius = this.radius;
}
if (this.blur) {
options.blur = this.blur;
}
if (this.max) {
options.max = this.max;
}
if (this.gradient) {
options.gradient = this.gradient;
}
this.mapObject = L.heatLayer(this.latLng, options);
L.DomEvent.on(this.mapObject, this.$listeners);
propsBinder(this, this.mapObject, props);
this.$watch('latLng', (newVal, _) => {
this.mapObject.setLatLngs(newVal);
}, { deep: true });
this.parentContainer = findRealParent(this.$parent);
this.parentContainer.addLayer(this, !this.visible);
},
beforeDestroy() {
this.parentContainer.removeLayer(this);
},
methods: {
setMinOpacity(newVal) {
this.mapObject.setOptions({ minOpacity: newVal });
},
setMaxZoom(newVal) {
this.mapObject.setOptions({ maxZoom: newVal });
},
setRadius(newVal) {
this.mapObject.setOptions({ radius: newVal });
},
setBlur(newVal) {
this.mapObject.setOptions({ blur: newVal });
},
setMax(newVal) {
this.mapObject.setOptions({ max: newVal });
},
setGradient(newVal) {
this.mapObject.setOptions({ gradient: newVal });
},
setVisible(newVal, oldVal) {
if (newVal === oldVal) return;
if (newVal) {
this.parentContainer.addLayer(this);
} else {
this.parentContainer.removeLayer(this);
}
},
addLatLng(value) {
this.mapObject.addLatLng(value);
}
}
});
})();

View File

@@ -1,2 +0,0 @@
// Here you can overwite values from default.js
window.config = {};

View File

@@ -1,27 +0,0 @@
(() => {
const endDate = new Date();
endDate.setUTCHours(0);
endDate.setUTCMinutes(0);
endDate.setUTCSeconds(0);
const startDate = new Date(endDate);
startDate.setUTCMonth(startDate.getMonth()-1);
window.defaultConfig = {
accentColor: '#3388ff',
startDate,
endDate,
map: {
center: L.latLng(0, 0),
zoom: 19,
maxNativeZoom: 19,
maxZoom: 21,
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
heatmap: {
max: 20,
radius: 25,
blur: 15,
gradient: null, // https://github.com/mourner/simpleheat/blob/gh-pages/simpleheat.js#L22
},
},
};
})();

View File

@@ -1,218 +0,0 @@
(() => {
const { LMap, LTileLayer, LMarker, LCircleMarker, LCircle, LPolyline } = Vue2Leaflet;
const config = deepmerge(window.defaultConfig, window.config);
new Vue({
el: '#app',
components: { vuejsDatepicker, LMap, LTileLayer, LMarker, LCircleMarker, LPolyline, LCircle },
data: {
users: [],
devices: {},
lastLocations: [],
locationHistory: {},
showLastLocations: true,
showLocationHistoryPoints: false,
showLocationHistoryLine: false,
showLocationHeatmap: false,
selectedUser: '',
selectedDevice: '',
startDate: config.startDate,
endDate: config.endDate,
showDownloadModal: false,
showInformationModal: false,
map: {
center: config.map.center,
zoom: config.map.zoom,
maxNativeZoom: config.map.maxNativeZoom,
maxZoom: config.map.maxZoom,
url: config.map.url,
attribution: config.map.attribution,
polyline: {
color: config.accentColor,
fillColor: 'transparent',
},
circle: {
color: config.accentColor,
fillColor: config.accentColor,
fillOpacity: 0.2,
},
circleMarker: {
radius: 4,
color: config.accentColor,
fillColor: '#fff',
fillOpacity: 1,
},
heatmap: {
max: config.map.heatmap.max,
radius: config.map.heatmap.radius,
blur: config.map.heatmap.radius,
gradient: config.map.heatmap.gradient,
},
},
information: {
ownTracks: {
version: '',
documentationUrl: 'https://owntracks.org/booklet/',
sourceCodeUrl: 'https://github.com/owntracks/recorder',
twitterUrl: 'https://twitter.com/OwnTracks',
},
ownTracksUi: {
sourceCodeUrl: 'https://github.com/linusg/owntracks-ui',
},
}
},
watch: {
selectedUser: async function () {
this.selectedDevice = '';
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
},
selectedDevice: async function () {
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
},
startDate: async function () {
this.locationHistory = await this.getLocationHistory();
},
endDate: async function () {
this.locationHistory = await this.getLocationHistory();
},
},
computed: {
locationHistoryLatLngs() {
const latLngs = [];
Object.keys(this.locationHistory).forEach((user) => {
Object.keys(this.locationHistory[user]).forEach((device) => {
this.locationHistory[user][device].forEach((l) => {
latLngs.push(L.latLng(l.lat, l.lon));
});
});
});
return latLngs;
},
startDateDisabledDates() {
return {
customPredictor: (date) => (date > this.endDate) || (date > new Date())
};
},
endDateDisabledDates() {
return {
customPredictor: (date) => (date < this.startDate) || (date > new Date())
};
},
},
methods: {
init: async function () {
const root = document.documentElement;
root.style.setProperty('--color-accent', config.accentColor);
this.users = await this.getUsers();
this.devices = await this.getDevices();
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
this.centerView();
await this.connectWebsocket();
this.information.ownTracks.version = await this.getVersion();
},
connectWebsocket: async function () {
const wsUrl = `${document.location.protocol.replace('http', 'ws')}//${document.location.host}/ws/last`;
const ws = new WebSocket(wsUrl);
console.log(`[WS] Connecting to ${wsUrl}...`);
ws.onopen = (e) => {
console.log('[WS] Connected');
ws.send('LAST');
};
ws.onclose = () => {
console.log('[WS] Disconnected. Reconnecting in one second...')
setTimeout(this.connectWebsocket, 1000);
};
ws.onmessage = async (msg) => {
if (msg.data) {
try {
const data = JSON.parse(msg.data);
if (data._type === 'location') {
console.log('[WS] Location update received');
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
}
} catch (err) {}
} else {
console.log('[WS] Ping');
}
};
},
getVersion: async function () {
const response = await fetch('/api/0/version');
const json = await response.json();
const version = json.version;
return version;
},
getUsers: async function () {
const response = await fetch('/api/0/list');
const json = await response.json();
const users = json.results;
return users;
},
getDevices: async function () {
const devices = {};
await Promise.all(this.users.map(async (user) => {
const response = await fetch(`/api/0/list?user=${user}`);
const json = await response.json();
const userDevices = json.results;
devices[user] = userDevices;
}));
return devices;
},
getLastLocations: async function () {
let url = '/api/0/last';
if (this.selectedUser !== '') {
url += `?&user=${this.selectedUser}`;
if (this.selectedDevice !== '') {
url += `&device=${this.selectedDevice}`;
}
}
const response = await fetch(url);
const json = await response.json();
return json;
},
getLocationHistory: async function () {
let users;
let devices;
if (this.selectedUser === '') {
users = this.users;
devices = { ...this.devices };
} else {
users = [this.selectedUser];
if (this.selectedDevice === '') {
devices = { [this.selectedUser]: this.devices[this.selectedUser] };
} else {
devices = { [this.selectedUser]: [this.selectedDevice] };
}
}
const locations = {};
await Promise.all(users.map(async (user) => {
locations[user] = {};
await Promise.all(devices[user].map(async (device) => {
const startDateString = `${this.startDate.toISOString().split('T')[0]}T00:00:00`;
const endDateString = `${this.endDate.toISOString().split('T')[0]}T23:59:59`;
const url = `/api/0/locations?from=${startDateString}&to=${endDateString}&format=json&user=${user}&device=${device}`;
const response = await fetch(url);
const json = await response.json();
const userDeviceLocations = json.data;
locations[user][device] = userDeviceLocations;
}));
}));
return locations;
},
centerView() {
if ((this.showLocationHistoryPoints || this.showLocationHistoryLine || this.showLocationHeatmap) && this.locationHistoryLatLngs.length > 0) {
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(this.locationHistoryLatLngs));
} else if (this.showLastLocations && this.lastLocations.length > 0) {
const locations = this.lastLocations.map((l) => L.latLng(l.lat, l.lon));
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(locations), {maxZoom: this.map.maxNativeZoom});
}
},
},
mounted() {
this.init();
},
});
})();

View File

@@ -1,270 +0,0 @@
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
:root {
--color-text: #333;
--color-background: #fff;
--color-accent: #3388ff;
--color-accent-text: #fff;
--drop-shadow: drop-shadow(0 10px 10px rgb(0, 0, 0, 0.2));
--dropdown-arrow: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2225%22%20height%3D%2210%22%3E%3Cpath%20fill%3D%22%23333%22%20fill-opacity%3D%221%22%20stroke%3D%22none%22%20d%3D%22M0%2C0%20L0%2C0%20L1%2C0%20L1%2C6%20L7%2C6%20L7%2C7%20L0%2C7%20z%22%20transform%3D%22rotate(-45%205%200)%22%20%2F%3E%3C%2Fsvg%3E");
}
html, body {
height: 100%;
}
body {
font-family: "Noto Sans", sans-serif;
font-size: 13px;
color: var(--color-text);
}
a {
color: var(--color-accent);
}
ul {
list-style: inside;
}
#app {
display: flex;
min-height: 100%;
flex-direction: column;
}
#app > header {
display: flex;
padding: 20px;
white-space: nowrap;
overflow-x: auto;
color: var(--color-accent-text);
background: var(--color-accent);
}
#app > header > nav {
display: flex;
flex: 1;
}
#app > header > nav:not(:first-child) {
margin-left: 20px;
}
#app > header > nav.nav-shrink {
flex: 0 1 auto;
}
#app > header > nav .nav-item {
display: inline-block;
}
#app > header > nav .nav-item:not(:first-child) {
margin-left: 20px;
}
#app > main {
flex: 1;
/* https://github.com/linusg/owntracks-ui/issues/6 */
display: flex;
}
.button,
.vdp-datepicker input {
cursor: pointer;
color: var(--color-text);
background: var(--color-background);
border: 0;
border-radius: 18px;
padding: 8px 16px;
}
.button-outline {
border: 1px solid var(--color-background);
color: var(--color-accent-text);
background: transparent;
}
.button-flat {
color: var(--color-accent-text);
background: transparent;
}
.button-icon {
padding: 8px 0;
}
.dropdown {
display: inline-block;
}
.dropdown-button,
.vdp-datepicker input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: var(--dropdown-arrow);
background-repeat: no-repeat;
background-position-x: right;
background-position-y: center;
padding-right: 30px;
}
.dropdown-body {
display: none;
position: absolute;
margin-top: 12px;
padding: 8px 0;
border-radius: 3px;
color: var(--color-text);
background: var(--color-background);
filter: var(--drop-shadow);
z-index: 2000;
}
.dropdown-body::before,
.vdp-datepicker .vdp-datepicker__calendar::before {
content: "";
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid var(--color-background);
position: absolute;
top: -20px;
left: 20px;
}
.dropdown:focus-within .dropdown-body {
display: block;
}
.dropdown-body label {
cursor: pointer;
display: block;
padding: 8px 15px;
}
.dropdown-body label:hover {
background: rgba(0, 0, 0, 0.1);
}
.dropdown-body label input[type=checkbox] {
position: relative;
top: 2px;
}
.modal {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.4);
filter: var(--drop-shadow);
z-index: 4000;
}
.modal .modal-container {
min-width: 300px;
padding: 20px;
border-radius: 3px;
background: var(--color-background);
}
.modal .modal-container .modal-close-button {
display: block;
border: none;
float: right;
font-size: 24px;
line-height: 16px;
background: transparent;
cursor: pointer;
}
.location-popup-face {
border-radius: 50%;
border: 2px solid var(--color-background);
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
}
.location-popup-detail {
white-space: nowrap;
}
.leaflet-container {
/* https://github.com/linusg/owntracks-ui/issues/6 */
height: auto !important;
}
.leaflet-container .leaflet-popup {
filter: var(--drop-shadow);
}
.leaflet-container .leaflet-popup .leaflet-popup-content-wrapper {
border-radius: 3px;
box-shadow: none;
}
.leaflet-container .leaflet-popup a.leaflet-popup-close-button {
padding: 5px 5px 0 0;
}
.leaflet-popup-tip-container .leaflet-popup-tip {
box-shadow: none;
}
.vdp-datepicker {
position: static !important;
display: inline-block;
white-space: initial;
overflow: initial;
z-index: 3000;
}
.vdp-datepicker input {
width: 120px;
}
.vdp-datepicker .vdp-datepicker__calendar {
color: var(--color-text);
border: 0;
border-radius: 3px;
z-index: 4000;
margin-top: 12px;
filter: var(--drop-shadow);
}
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).day:hover,
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).month:hover,
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).year:hover {
border-color: var(--color-accent);
}
.vdp-datepicker .vdp-datepicker__calendar .cell.selected,
.vdp-datepicker .vdp-datepicker__calendar .cell.selected:hover {
background: var(--color-accent);
color: var(--color-accent-text);
}
header .mdi {
position: relative;
top: 5px;
margin-right: 3px;
}
header .button .mdi {
line-height: 0;
}
.mdi-16px.mdi-set,
.mdi-16px.mdi::before {
font-size: 16px;
}

5
tests/.eslintrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
env: {
jest: true,
},
};

217
tests/api.test.js Normal file
View File

@@ -0,0 +1,217 @@
import * as api from "@/api";
describe("API", () => {
beforeEach(() => {
fetch.resetMocks();
});
test("getVersion", async () => {
fetch.mockResponse(JSON.stringify({ version: "1.2.3" }));
const version = await api.getVersion();
expect(version).toBe("1.2.3");
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0]).toEqual("http://localhost/api/0/version");
});
test("getUsers", async () => {
fetch.mockResponse(JSON.stringify({ results: ["foo", "bar"] }));
const users = await api.getUsers();
expect(users).toEqual(["foo", "bar"]);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0]).toEqual("http://localhost/api/0/list");
});
test("getDevices", async () => {
fetch.mockResponses(
[JSON.stringify({ results: ["phone", "tablet"] })],
[JSON.stringify({ results: ["laptop"] })]
);
const devices = await api.getDevices(["foo", "bar"]);
expect(devices).toEqual({ foo: ["phone", "tablet"], bar: ["laptop"] });
expect(fetch.mock.calls.length).toEqual(2);
expect(fetch.mock.calls[0][0]).toEqual(
"http://localhost/api/0/list?user=foo"
);
expect(fetch.mock.calls[1][0]).toEqual(
"http://localhost/api/0/list?user=bar"
);
});
test("getLastLocations", async () => {
const response = [
{
_type: "location",
tid: "pp",
lat: 51.47879,
lon: -0.010677,
tst: 0,
_http: true,
topic: "owntracks/ping/ping",
username: "ping",
device: "ping",
ghash: "gcpuzg2",
isotst: "1970-01-01T00:00:00Z",
disptst: "1970-01-01 00:00:00",
},
];
fetch.mockResponse(JSON.stringify(response));
const lastLocation = await api.getLastLocations();
expect(lastLocation).toEqual(response);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0]).toEqual("http://localhost/api/0/last");
});
test("getLastLocations with user", async () => {
const response = [
// Other properties not relevant for testing
{
username: "foo",
device: "phone",
},
{
username: "foo",
device: "tablet",
},
];
fetch.mockResponse(JSON.stringify(response));
const lastLocation = await api.getLastLocations("foo");
expect(lastLocation).toEqual(response);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0]).toEqual(
"http://localhost/api/0/last?user=foo"
);
});
test("getLastLocations with user and device", async () => {
const response = [
// Other properties not relevant for testing
{
username: "foo",
device: "phone",
},
];
fetch.mockResponse(JSON.stringify(response));
const lastLocation = await api.getLastLocations("foo", "phone");
expect(lastLocation).toEqual(response);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0]).toEqual(
"http://localhost/api/0/last?user=foo&device=phone"
);
});
test("getUserDeviceLocationHistory", async () => {
const response = {
count: 1,
data: [
{
batt: 100,
lon: -0.010677,
acc: 20,
bs: 1,
vac: 10,
topic: "owntracks/foo/phone",
lat: 51.47879,
conn: "w",
tst: 1568841029,
alt: 31,
_type: "location",
tid: "AD",
_http: true,
ghash: "gcpv4k9",
isorcv: "2019-09-18T21:10:29Z",
isotst: "2019-09-18T21:10:29Z",
disptst: "2019-09-18 21:10:29",
},
],
status: 200,
};
fetch.mockResponse(JSON.stringify(response));
const locationHistory = await api.getUserDeviceLocationHistory(
"foo",
"phone",
"1970-01-01T00:00:00",
"1970-12-31T23:59:59"
);
expect(locationHistory).toEqual(response.data);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0]).toEqual(
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
);
});
test("getLocationHistory", async () => {
fetch.mockResponses(
[
JSON.stringify({
count: 1,
data: [
{
topic: "owntracks/foo/phone",
},
],
status: 200,
}),
],
[
JSON.stringify({
count: 1,
data: [
{
topic: "owntracks/foo/tablet",
},
],
status: 200,
}),
],
[
JSON.stringify({
count: 1,
data: [
{
topic: "owntracks/bar/laptop",
},
],
status: 200,
}),
]
);
const locationHistory = await api.getLocationHistory(
{ foo: ["phone", "tablet"], bar: ["laptop"] },
"1970-01-01T00:00:00",
"1970-12-31T23:59:59"
);
expect(locationHistory).toEqual({
foo: {
phone: [{ topic: "owntracks/foo/phone" }],
tablet: [{ topic: "owntracks/foo/tablet" }],
},
bar: { laptop: [{ topic: "owntracks/bar/laptop" }] },
});
expect(fetch.mock.calls.length).toEqual(3);
expect(fetch.mock.calls[0][0]).toEqual(
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
);
expect(fetch.mock.calls[1][0]).toEqual(
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=tablet&format=json"
);
expect(fetch.mock.calls[2][0]).toEqual(
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=bar&device=laptop&format=json"
);
});
});

1
tests/setup.js Normal file
View File

@@ -0,0 +1 @@
require("jest-fetch-mock").enableMocks();

124
tests/util.test.js Normal file
View File

@@ -0,0 +1,124 @@
import config from "@/config";
import {
getApiUrl,
isIsoDateTime,
degreesToRadians,
distanceBetweenCoordinates,
humanReadableDistance,
} from "@/util";
describe("getApiUrl", () => {
test("without base URL", () => {
// See testURL in jest.config.js
expect(getApiUrl("foo").href).toBe("http://localhost/foo");
expect(getApiUrl("/foo").href).toBe("http://localhost/foo");
expect(getApiUrl("/foo/bar").href).toBe("http://localhost/foo/bar");
});
test("with base URL", () => {
config.api.baseUrl = "http://example.com/owntracks";
expect(getApiUrl("foo").href).toBe("http://example.com/owntracks/foo");
expect(getApiUrl("/foo").href).toBe("http://example.com/owntracks/foo");
expect(getApiUrl("/foo/bar").href).toBe(
"http://example.com/owntracks/foo/bar"
);
});
});
describe("isIsoDateTime", () => {
test("no match", () => {
expect(isIsoDateTime("foo")).toBe(false);
expect(isIsoDateTime("2019")).toBe(false);
expect(isIsoDateTime("2019-09")).toBe(false);
expect(isIsoDateTime("2019.09.27")).toBe(false);
expect(isIsoDateTime("2019_09_27")).toBe(false);
expect(isIsoDateTime("2019/09/27")).toBe(false);
expect(isIsoDateTime("27-09-2019")).toBe(false);
expect(isIsoDateTime("27.09.2019")).toBe(false);
expect(isIsoDateTime("27_09_2019")).toBe(false);
expect(isIsoDateTime("27/09/2019")).toBe(false);
expect(isIsoDateTime("0000-00-00")).toBe(false);
expect(isIsoDateTime("1234-56-78")).toBe(false);
expect(isIsoDateTime("0000-00-00T00:00:00")).toBe(false);
expect(isIsoDateTime("0000-01-01T25:60:60")).toBe(false);
expect(isIsoDateTime("2019-12-14T99:00:00")).toBe(false);
expect(isIsoDateTime("2019-12-14 25:60:60")).toBe(false);
});
test("match", () => {
expect(isIsoDateTime("0000-01-01T00:00:00")).toBe(true);
expect(isIsoDateTime("0000-01-01T12:34:56")).toBe(true);
expect(isIsoDateTime("0000-01-01T23:59:59")).toBe(true);
expect(isIsoDateTime("2019-09-27T00:00:00")).toBe(true);
expect(isIsoDateTime("2019-09-27T12:34:56")).toBe(true);
expect(isIsoDateTime("2019-09-27T23:59:59")).toBe(true);
expect(isIsoDateTime("9999-12-31T00:00:00")).toBe(true);
expect(isIsoDateTime("9999-12-31T12:34:56")).toBe(true);
expect(isIsoDateTime("9999-12-31T23:59:59")).toBe(true);
});
});
describe("degreesToRadians", () => {
test("expected results", () => {
expect(degreesToRadians(0)).toBe(0);
expect(degreesToRadians(45)).toBe(0.7853981633974483);
expect(degreesToRadians(90)).toBe(1.5707963267948966);
expect(degreesToRadians(180)).toBe(3.141592653589793);
expect(degreesToRadians(360)).toBe(6.283185307179586);
expect(degreesToRadians(-180)).toBe(-3.141592653589793);
});
});
describe("distanceBetweenCoordinates", () => {
test("expected results", () => {
expect(
distanceBetweenCoordinates({ lat: 0, lng: 0 }, { lat: 0, lng: 0 })
).toBe(0);
// The Shard - Victoria Memorial
expect(
distanceBetweenCoordinates(
{ lat: 51.5046678, lng: -0.0870769 },
{ lat: 51.501752, lng: -0.1408258 }
)
// 3.74km according to Google Maps
).toBe(3734.3632679046705);
// Gatwick Airport - Heathrow Airport
expect(
distanceBetweenCoordinates(
{ lat: 51.1526929, lng: -0.1752475 },
{ lat: 51.4720694, lng: -0.4499871 }
)
// 40km according to Google Maps
).toBe(40321.457586930104);
// Berlin - San Francisco
expect(
distanceBetweenCoordinates(
{ lat: 52.5067614, lng: 13.284651 },
{ lat: 37.7576948, lng: -122.4726193 }
)
// 9,102.73km according to Google Maps
).toBe(9105627.810109457);
});
});
describe("humanReadableDistance", () => {
test("expected results", () => {
expect(humanReadableDistance(0)).toBe("0 m");
expect(humanReadableDistance(1)).toBe("1 m");
expect(humanReadableDistance(123)).toBe("123 m");
expect(humanReadableDistance(123.4567)).toBe("123.5 m");
expect(humanReadableDistance(999)).toBe("999 m");
expect(humanReadableDistance(1000)).toBe("1 km");
expect(humanReadableDistance(9000)).toBe("9 km");
expect(humanReadableDistance(9900)).toBe("9.9 km");
expect(humanReadableDistance(9990)).toBe("10 km");
expect(humanReadableDistance(9999)).toBe("10 km");
expect(humanReadableDistance(9999.0)).toBe("10 km");
expect(humanReadableDistance(9999.9999)).toBe("10 km");
expect(humanReadableDistance(100000)).toBe("100 km");
expect(humanReadableDistance(-42)).toBe("-42 m");
});
});

27
vue.config.js Normal file
View File

@@ -0,0 +1,27 @@
const fs = require("fs");
const webpack = require("webpack");
const MomentLocalesPlugin = require("moment-locales-webpack-plugin");
const packageJson = fs.readFileSync("./package.json");
const version = JSON.parse(packageJson).version;
module.exports = {
configureWebpack: {
plugins: [
new webpack.DefinePlugin({
"process.env": {
PACKAGE_VERSION: `"${version}"`,
},
}),
new MomentLocalesPlugin(),
],
},
pluginOptions: {
i18n: {
locale: "en",
fallbackLocale: "en",
localeDir: "locales",
},
},
};

11172
yarn.lock Normal file

File diff suppressed because it is too large Load Diff