├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── activities ├── __init__.py ├── auth │ ├── __init__.py │ ├── flask_app.py │ └── templates │ │ ├── auth_complete.html │ │ ├── main.html │ │ └── sync_complete.html ├── generator │ ├── __init__.py │ ├── db.py │ └── valuerange.py └── run.py ├── config-example.json ├── mypy.ini ├── requirements-dev.txt ├── requirements.txt ├── screenshot.png ├── setup.py ├── tox.ini └── web ├── app.js ├── index.html └── style.css /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true, 5 | "jquery": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 11 10 | }, 11 | "rules": { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tox: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | python-version: [3.9] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install tox 22 | - name: Test with tox 23 | run: tox -e format 24 | 25 | eslint: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Set up Node.js 30 | uses: actions/setup-node@v1 31 | with: 32 | node-version: '12.x' 33 | - name: Install dependencies 34 | run: npm install --no-save eslint 35 | - name: Run ESLint 36 | run: node_modules/.bin/eslint web/app.js 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env/ 2 | .venv/ 3 | .tox/ 4 | 5 | node_modules 6 | __pycache__ 7 | account.json 8 | config.json 9 | activities.js 10 | data.db 11 | *.egg-info/ 12 | *.ipynb* 13 | build 14 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=.tox,.env,.venv,.eggs,build,migrations,south_migrations 13 | 14 | # Pickle collected data for later comparisons. 15 | persistent=yes 16 | 17 | # List of plugins (as comma separated values of python modules names) to load, 18 | # usually to register additional checkers. 19 | load-plugins = pylint.extensions.check_docs 20 | 21 | # Use multiple processes to speed up Pylint. 22 | jobs=0 23 | 24 | # Allow loading of arbitrary C extensions. Extensions are imported into the 25 | # active Python interpreter and may run arbitrary code. 26 | unsafe-load-any-extension=no 27 | 28 | # A comma-separated list of package or module names from where C extensions may 29 | # be loaded. Extensions are loading into the active Python interpreter and may 30 | # run arbitrary code 31 | extension-pkg-whitelist= 32 | 33 | 34 | [MESSAGES CONTROL] 35 | 36 | # Only show warnings with the listed confidence levels. Leave empty to show 37 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 38 | confidence= 39 | 40 | # Enable the message, report, category or checker with the given id(s). You can 41 | # either give multiple identifier separated by comma (,) or put this option 42 | # multiple time. See also the "--disable" option for examples. 43 | #enable= 44 | 45 | # Disable the message, report, category or checker with the given id(s). You 46 | # can either give multiple identifiers separated by comma (,) or put this 47 | # option multiple times (only on the command line, not in the configuration 48 | # file where it should appear only once).You can also use "--disable=all" to 49 | # disable everything first and then reenable specific checks. For example, if 50 | # you want to run only the similarities checker, you can use "--disable=all 51 | # --enable=similarities". If you want to run only the classes checker, but have 52 | # no Warning level messages displayed, use"--disable=all --enable=classes 53 | # --disable=W" 54 | disable= 55 | bad-continuation, 56 | missing-docstring, 57 | no-init, 58 | no-member, 59 | no-value-for-parameter, 60 | too-few-public-methods, 61 | too-many-arguments, 62 | too-many-locals 63 | 64 | 65 | [REPORTS] 66 | 67 | # Set the output format. Available formats are text, parseable, colorized, msvs 68 | # (visual studio) and html. You can also give a reporter class, eg 69 | # mypackage.mymodule.MyReporterClass. 70 | output-format=colorized 71 | 72 | # Put messages in a separate file for each module / package specified on the 73 | # command line instead of printing them on stdout. Reports (if any) will be 74 | # written in a file name "pylint_global.[txt|html]". 75 | files-output=no 76 | 77 | # Tells whether to display a full report or only the messages 78 | reports=no 79 | 80 | # Python expression which should return a note less than 10 (10 is the highest 81 | # note). You have access to the variables errors warning, statement which 82 | # respectively contain the number of errors / warnings messages and the total 83 | # number of statements analyzed. This is used by the global evaluation report 84 | # (RP0004). 85 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 86 | 87 | # Template used to display messages. This is a python new-style format string 88 | # used to format the message information. See doc for all details 89 | #msg-template= 90 | 91 | 92 | [LOGGING] 93 | 94 | # Logging modules to check that the string format arguments are in logging 95 | # function parameter format 96 | logging-modules=logging 97 | 98 | 99 | [MISCELLANEOUS] 100 | 101 | # List of note tags to take in consideration, separated by a comma. 102 | notes=FIXME,XXX,TODO 103 | 104 | 105 | [SIMILARITIES] 106 | 107 | # Minimum lines number of a similarity. 108 | min-similarity-lines=5 109 | 110 | # Ignore comments when computing similarities. 111 | ignore-comments=no 112 | 113 | # Ignore docstrings when computing similarities. 114 | ignore-docstrings=no 115 | 116 | # Ignore imports when computing similarities. 117 | ignore-imports=no 118 | 119 | 120 | [VARIABLES] 121 | 122 | # Tells whether we should check for unused import in __init__ files. 123 | init-import=no 124 | 125 | # A regular expression matching the name of dummy variables (i.e. expectedly 126 | # not used). 127 | dummy-variables-rgx=_$|dummy|tmp$ 128 | 129 | # List of additional names supposed to be defined in builtins. Remember that 130 | # you should avoid to define new builtins when possible. 131 | additional-builtins= 132 | 133 | # List of strings which can identify a callback function by name. A callback 134 | # name must start or end with one of those strings. 135 | callbacks=cb_,_cb 136 | 137 | 138 | [FORMAT] 139 | 140 | # Maximum number of characters on a single line. 141 | max-line-length=120 142 | 143 | # Regexp for a line that is allowed to be longer than the limit. 144 | ignore-long-lines=^\s*(# )??$ 145 | 146 | # Allow the body of an if to be on the same line as the test if there is no 147 | # else. 148 | single-line-if-stmt=no 149 | 150 | # List of optional constructs for which whitespace checking is disabled. `dict- 151 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 152 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 153 | # `empty-line` allows space-only lines. 154 | no-space-check=trailing-comma 155 | 156 | # Maximum number of lines in a module 157 | max-module-lines=500 158 | 159 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 160 | # tab). 161 | indent-string=' ' 162 | 163 | # Number of spaces of indent required inside a hanging or continued line. 164 | indent-after-paren=4 165 | 166 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 167 | expected-line-ending-format=LF 168 | 169 | 170 | [BASIC] 171 | 172 | # List of builtins function names that should not be used, separated by a comma 173 | bad-functions=map,filter,input 174 | 175 | # Good variable names which should always be accepted, separated by a comma 176 | good-names=i,_ 177 | 178 | # Bad variable names which should always be refused, separated by a comma 179 | bad-names=foo,bar,baz,toto,tutu,tata,wtf 180 | 181 | # Colon-delimited sets of names that determine each other's naming style when 182 | # the name regexes allow several styles. 183 | name-group= 184 | 185 | # Include a hint for the correct naming format with invalid-name 186 | include-naming-hint=yes 187 | 188 | # Regular expression matching correct function names 189 | function-rgx=([a-z_][a-z0-9_]{1,40}|test_[A-Za-z0-9_]{3,70})$ 190 | 191 | # Naming hint for function names 192 | function-name-hint=[a-z_][a-z0-9_]{1,40}$ 193 | 194 | # Regular expression matching correct variable names 195 | variable-rgx=[a-z_][a-z0-9_]{0,40}$ 196 | 197 | # Naming hint for variable names 198 | variable-name-hint=[a-z_][a-z0-9_]{0,40}$ 199 | 200 | # Regular expression matching correct constant names 201 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|(urls|urlpatterns|register))$ 202 | 203 | # Naming hint for constant names 204 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 205 | 206 | # Regular expression matching correct attribute names 207 | attr-rgx=[a-z_][a-z0-9_]{0,30}$ 208 | 209 | # Naming hint for attribute names 210 | attr-name-hint=[a-z_][a-z0-9_]{0,30}$ 211 | 212 | # Regular expression matching correct argument names 213 | argument-rgx=[a-z_][a-z0-9_]{0,30}$ 214 | 215 | # Naming hint for argument names 216 | argument-name-hint=[a-z_][a-z0-9_]{0,30}$ 217 | 218 | # Regular expression matching correct class attribute names 219 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{1,40}|(__.*__))$ 220 | 221 | # Naming hint for class attribute names 222 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{1,40}|(__.*__))$ 223 | 224 | # Regular expression matching correct inline iteration names 225 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 226 | 227 | # Naming hint for inline iteration names 228 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 229 | 230 | # Regular expression matching correct class names 231 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 232 | 233 | # Naming hint for class names 234 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 235 | 236 | # Regular expression matching correct module names 237 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 238 | 239 | # Naming hint for module names 240 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 241 | 242 | # Regular expression matching correct method names 243 | method-rgx=[a-z_][a-z0-9_]{1,30}$ 244 | 245 | # Naming hint for method names 246 | method-name-hint=[a-z_][a-z0-9_]{1,30}$ 247 | 248 | # Regular expression which should only match function or class names that do 249 | # not require a docstring. 250 | no-docstring-rgx=^_ 251 | 252 | # Minimum line length for functions/classes that require docstrings, shorter 253 | # ones are exempt. 254 | docstring-min-length=-1 255 | 256 | 257 | [ELIF] 258 | 259 | # Maximum number of nested blocks for function / method body 260 | max-nested-blocks=5 261 | 262 | 263 | [TYPECHECK] 264 | 265 | # Tells whether missing members accessed in mixin class should be ignored. A 266 | # mixin class is detected if its name ends with "mixin" (case insensitive). 267 | ignore-mixin-members=yes 268 | 269 | # List of module names for which member attributes should not be checked 270 | # (useful for modules/projects where namespaces are manipulated during runtime 271 | # and thus existing member attributes cannot be deduced by static analysis. 272 | ignored-modules = 273 | 274 | # List of classes names for which member attributes should not be checked 275 | # (useful for classes with attributes dynamically set). 276 | ignored-classes= 277 | 278 | # List of members which are set dynamically and missed by pylint inference 279 | # system, and so shouldn't trigger E1101 when accessed. Python regular 280 | # expressions are accepted. 281 | generated-members= 282 | 283 | 284 | [SPELLING] 285 | 286 | # Spelling dictionary name. Available dictionaries: none. To make it working 287 | # install python-enchant package. 288 | spelling-dict= 289 | 290 | # List of comma separated words that should not be checked. 291 | spelling-ignore-words= 292 | 293 | # A path to a file that contains private dictionary; one word per line. 294 | spelling-private-dict-file= 295 | 296 | # Tells whether to store unknown words to indicated private dictionary in 297 | # --spelling-private-dict-file option instead of raising a message. 298 | spelling-store-unknown-words=no 299 | 300 | 301 | [DESIGN] 302 | 303 | # Maximum number of arguments for function / method 304 | max-args=5 305 | 306 | # Argument names that match this expression will be ignored. Default to name 307 | # with leading underscore 308 | ignored-argument-names=_.* 309 | 310 | # Maximum number of locals for function / method body 311 | max-locals=15 312 | 313 | # Maximum number of return / yield for function / method body 314 | max-returns=6 315 | 316 | # Maximum number of branch for function / method body 317 | max-branches=12 318 | 319 | # Maximum number of statements in function / method body 320 | max-statements=50 321 | 322 | # Maximum number of parents for a class (see R0901). 323 | max-parents=8 324 | 325 | # Maximum number of attributes for a class (see R0902). 326 | max-attributes=7 327 | 328 | # Minimum number of public methods for a class (see R0903). 329 | min-public-methods=1 330 | 331 | # Maximum number of public methods for a class (see R0904). 332 | max-public-methods=20 333 | 334 | # Maximum number of boolean expressions in a if statement 335 | max-bool-expr=5 336 | 337 | 338 | [CLASSES] 339 | 340 | # List of method names used to declare (i.e. assign) instance attributes. 341 | defining-attr-methods=__init__,__new__,setUp 342 | 343 | # List of valid names for the first argument in a class method. 344 | valid-classmethod-first-arg=cls 345 | 346 | # List of valid names for the first argument in a metaclass class method. 347 | valid-metaclass-classmethod-first-arg=mcs 348 | 349 | # List of member names, which should be excluded from the protected access 350 | # warning. 351 | exclude-protected=_meta 352 | 353 | 354 | [IMPORTS] 355 | 356 | # Deprecated modules which should not be used, separated by a comma 357 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 358 | 359 | # Create a graph of every (i.e. internal and external) dependencies in the 360 | # given file (report RP0402 must not be disabled) 361 | import-graph= 362 | 363 | # Create a graph of external dependencies in the given file (report RP0402 must 364 | # not be disabled) 365 | ext-import-graph= 366 | 367 | # Create a graph of internal dependencies in the given file (report RP0402 must 368 | # not be disabled) 369 | int-import-graph= 370 | 371 | 372 | [EXCEPTIONS] 373 | 374 | # Exceptions that will emit a warning when being caught. Defaults to 375 | # "Exception" 376 | overgeneral-exceptions=Exception 377 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Florian Pigorsch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include requirements-dev.txt 3 | 4 | recursive-include activities/auth/templates * 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: setup 2 | setup: 3 | python3.9 -m venv .env 4 | .env/bin/pip install --use-pep517 --upgrade pip 5 | .env/bin/pip install --use-pep517 --upgrade -r requirements.txt 6 | 7 | .PHONY: setup-dev 8 | setup-dev: setup 9 | .env/bin/pip install --use-pep517 --upgrade -r requirements-dev.txt 10 | 11 | .PHONY: format 12 | format: 13 | .env/bin/black activities -l 120 14 | 15 | .PHONY: lint 16 | lint: mypy pylint 17 | 18 | .PHONY: pylint 19 | pylint: 20 | .env/bin/pylint activities 21 | 22 | .PHONY: mypy 23 | mypy: 24 | PYTHONPATH=. .env/bin/mypy activities 25 | 26 | .PHONY: auth 27 | auth: 28 | PYTHONPATH=. .env/bin/python activities/run.py --auth 29 | 30 | .PHONY: run 31 | run: 32 | PYTHONPATH=. .env/bin/python activities/run.py 33 | 34 | .PHONY: run+sync 35 | run+sync: 36 | PYTHONPATH=. .env/bin/python activities/run.py --sync 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Activities 2 | 3 | [![Any color you like](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 4 | ![Continuous Integration](https://github.com/flopp/activities/workflows/Continuous%20Integration/badge.svg) 5 | 6 | 7 | Your self-hosted activities overview (running, cycling, ...). Synced with [Strava](https://www.strava.com). 8 | 9 | https://activities.flopp.net 10 | 11 | ![Screenshot](https://raw.githubusercontent.com/flopp/activities/master/screenshot.png "Screenshot") 12 | 13 | 14 | ## Features 15 | 16 | - Built-in http server to authenticate with Strava. 17 | - Fetching of Strava activities. 18 | - Visited POI (predefined point-of-interest) matching. 19 | - Filtering by activity name, activity type, min/max distance, visited POI. 20 | - Running streak computation. 21 | - Heatmaps. 22 | 23 | ## Usage 24 | 25 | ### Installation 26 | 27 | ``` 28 | git clone https://github.com/flopp/activities.git 29 | cd activities 30 | python3 -m venv .env 31 | .env/bin/pip install --upgrade pip 32 | .env/bin/pip install . 33 | ``` 34 | 35 | ### Fetch API Config from Strava (once!) 36 | 37 | 1. Create an "Application" on https://www.strava.com/settings/api; for "Authorization Callback Domain" use `localhost`, for all other properties you can basically use whatever you want ;) 38 | 2. Copy `config-example.json` to `config.json` and fill in the "Client ID" and the "Client Secret" from the "My API Application" section on https://www.strava.com/settings/api. 39 | 40 | ### Authenticate with Strava (once!) 41 | 42 | ``` 43 | .env/bin/activities \ 44 | --auth 45 | ``` 46 | 47 | Now a web browser window should open with an "Authenticate with Strava" button. If not, manually open `localhost:5000` in a web browser of your choice. Click "Authenticate with Strava". Allow access for the app. 48 | The authentication data is now saved in `data.db` for later use. 49 | 50 | ### Sync 51 | 52 | ``` 53 | .env/bin/activities \ 54 | --sync \ 55 | --browser 56 | ``` 57 | 58 | This fetches your Strava data, creates a static website, and opens a browser to view the website. 59 | You can also manually point a web browser of your choice to `file:///INSTALLATION_PATH/web/index.html`... 60 | 61 | ### Visited POI Computation 62 | 63 | If you want to know which points-of-interest (POI), e.g. peaks of mountains, you have visited on each activity, create a JSON file containing the names and lat/lon pairs of your POI, e.g. 64 | 65 | ``` 66 | { 67 | "Belchen": {"lat": 47.822496, "lon": 7.833198}, 68 | "Feldberg": {"lat": 47.873986, "lon": 8.004683}, 69 | "Hinterwaldkopf": {"lat": 47.918979, "lon": 8.016681}, 70 | "Kandel": {"lat": 48.062517, "lon": 8.011391}, 71 | "Kybfelsen": {"lat": 47.960851, "lon": 7.885071}, 72 | "Rosskopf": {"lat": 48.010010, "lon": 7.901702}, 73 | "Schauinsland": {"lat": 47.911940, "lon": 7.898506}, 74 | "Schönberg": {"lat": 47.954722, "lon": 7.805504} 75 | } 76 | ``` 77 | 78 | Then just add the option `--poi mypoi.json` to your `.env/bin/activities` command. 79 | 80 | 81 | ## Made with 82 | 83 | - [Bulma](https://bulma.io/) 84 | - [Click](https://click.palletsprojects.com/) 85 | - [Flask](https://flask.palletsprojects.com/) 86 | - [heatmap.js](https://www.patrick-wied.at/static/heatmapjs/) 87 | - [geopy](https://github.com/geopy/geopy) 88 | - [jQuery](https://jquery.com/) 89 | - [Leaflet](https://leafletjs.com/) 90 | - [Leaflet.BeautifyMarker](https://github.com/masajid390/BeautifyMarker) 91 | - [Leaflet.distance-markers](https://github.com/adoroszlai/leaflet-distance-markers) 92 | - [Leaflet.encoded](https://github.com/jieter/Leaflet.encoded) 93 | - [noUiSlider](https://refreshless.com/nouislider/) 94 | - [polyline](https://github.com/hicsail/polyline) 95 | - [SQLAlchemy](https://www.sqlalchemy.org) 96 | - [Stravalib](https://github.com/hozn/stravalib) 97 | 98 | ## License 99 | 100 | ``` 101 | MIT License 102 | 103 | Copyright (c) 2020 Florian Pigorsch 104 | ``` 105 | -------------------------------------------------------------------------------- /activities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flopp/activities/f0bcf06e071123b437e7cb667116c4f120f8c913/activities/__init__.py -------------------------------------------------------------------------------- /activities/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flopp/activities/f0bcf06e071123b437e7cb667116c4f120f8c913/activities/auth/__init__.py -------------------------------------------------------------------------------- /activities/auth/flask_app.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from typing import Union 4 | 5 | import flask 6 | from werkzeug.wrappers import Response 7 | import stravalib # type: ignore 8 | 9 | from activities.generator import Generator 10 | from activities.generator.db import init_db, Auth 11 | 12 | 13 | app = flask.Flask(__name__) 14 | 15 | 16 | def configure(config: str, data: str, pois: str) -> None: 17 | with open(config, encoding="utf-8") as f: 18 | config_content = json.load(f) 19 | 20 | app.config["client_id"] = config_content["client_id"] 21 | app.config["client_secret"] = config_content["client_secret"] 22 | app.config["data"] = data 23 | app.config["config"] = config 24 | app.config["pois"] = pois 25 | 26 | 27 | @app.route("/") 28 | def homepage() -> Union[str, Response]: 29 | session = init_db(app.config["data"]) 30 | auth_info = session.query(Auth).first() 31 | if auth_info: 32 | return flask.redirect(flask.url_for("auth_complete")) 33 | 34 | client = stravalib.client.Client() 35 | auth_url = client.authorization_url( 36 | client_id=app.config["client_id"], 37 | scope=None, 38 | redirect_uri="http://localhost:5000/auth", 39 | ) 40 | return flask.render_template("main.html", auth_url=auth_url, data_file=app.config["data"]) 41 | 42 | 43 | @app.route("/auth_complete") 44 | def auth_complete() -> str: 45 | return flask.render_template("auth_complete.html") 46 | 47 | 48 | @app.route("/auth") 49 | def auth() -> Response: 50 | code = flask.request.args.get("code", "") 51 | client = stravalib.client.Client() 52 | token = client.exchange_code_for_token( 53 | client_id=app.config["client_id"], 54 | client_secret=app.config["client_secret"], 55 | code=code, 56 | ) 57 | 58 | session = init_db(app.config["data"]) 59 | auth_data = Auth( 60 | access_token=token["access_token"], 61 | refresh_token=token["refresh_token"], 62 | expires_at=datetime.datetime.fromtimestamp(token["expires_at"]), 63 | ) 64 | 65 | session.add(auth_data) 66 | session.commit() 67 | 68 | return flask.redirect(flask.url_for("auth_complete")) 69 | 70 | 71 | @app.route("/sync") 72 | def sync() -> str: 73 | generator = Generator(app.config["config"], app.config["data"], app.config["pois"]) 74 | generator.sync() 75 | 76 | return flask.render_template("sync_complete.html") 77 | -------------------------------------------------------------------------------- /activities/auth/templates/auth_complete.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Authentication complete 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |

Authenticate complete

17 |
18 |
19 |

20 | You are now ready to sync activities. 21 |

22 |


Sync

23 |
24 |
25 |
26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /activities/auth/templates/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Authentication 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |

Authenticate

17 |
18 |
19 |

20 | The app needs a valid authentication token for Strava in order to access your activities. 21 |

22 |

23 | Clicking the button forwards you to Strava's authentication page, where you can grant access for the app. 24 |

25 |

26 | On success, the authentication token will be stored in {{ data_file }} for later use. 27 |

28 |


Authenticate with Strava

29 |
30 |
31 |
32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /activities/auth/templates/sync_complete.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Authentication complete 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |

Sync complete

17 |
18 |
19 |

20 | You are now ready to browse activities. 21 |

22 |
23 |
24 |
25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /activities/generator/__init__.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import time 4 | import sys 5 | from typing import Dict, List, Optional, Tuple 6 | 7 | import arrow # type: ignore 8 | import stravalib # type: ignore 9 | from sqlalchemy import func 10 | 11 | from activities.generator.db import init_db, clear_activities, update_or_create_activity, Athlete, Activity, Auth 12 | 13 | 14 | class Generator: 15 | def __init__(self, strava_config_path: str, db_path: str, pois_data_path: str): 16 | self.client = stravalib.Client() 17 | self.session = init_db(db_path) 18 | 19 | # Load the strava account configuration 20 | with open(strava_config_path, encoding="utf-8") as f: 21 | strava_config = json.load(f) 22 | if not {"client_id", "client_secret"} <= strava_config.keys(): 23 | raise KeyError("Missing keys from strava configuration.") 24 | 25 | self.client_data = {"id": strava_config["client_id"], "secret": strava_config["client_secret"]} 26 | 27 | # Load the POIs 28 | if pois_data_path: 29 | with open(pois_data_path, encoding="utf-8") as f: 30 | self.pois = json.load(f) 31 | else: 32 | self.pois = None 33 | 34 | auth = self.session.query(Auth).first() 35 | if not auth: 36 | raise Exception("Missing auth data in DB.") 37 | 38 | self.authdata = auth 39 | 40 | def check_access(self) -> None: 41 | assert self.authdata is not None 42 | now = datetime.datetime.fromtimestamp(time.time()) 43 | expires_at = self.authdata.expires_at 44 | print(f"Access token valid until {expires_at} (now is {now})") 45 | if expires_at is not None and now + datetime.timedelta(minutes=5) >= expires_at: 46 | print("Refreshing access token") 47 | response = self.client.refresh_access_token( 48 | client_id=self.client_data["id"], 49 | client_secret=self.client_data["secret"], 50 | refresh_token=self.authdata.refresh_token, 51 | ) 52 | # Update the authdata object 53 | self.authdata.access_token = response["access_token"] 54 | self.authdata.refresh_token = response["refresh_token"] 55 | self.authdata.expires_at = datetime.datetime.fromtimestamp(response["expires_at"]) 56 | self.session.commit() 57 | 58 | print(f"New access token will expire at {expires_at}") 59 | 60 | self.client.access_token = self.authdata.access_token 61 | print("Access ok") 62 | 63 | def sync(self, force: bool = False) -> None: 64 | self.check_access() 65 | strava_athlete = self.client.get_athlete() 66 | 67 | athlete = self.session.query(Athlete).filter_by(id=strava_athlete.id).first() 68 | if not athlete: 69 | athlete = Athlete( 70 | id=strava_athlete.id, 71 | firstname=strava_athlete.firstname, 72 | lastname=strava_athlete.lastname, 73 | ) 74 | self.session.add(athlete) 75 | self.session.commit() 76 | 77 | if force: 78 | print("Force sync -> clear existing activities") 79 | clear_activities(self.session) 80 | filters = {} 81 | else: 82 | last_activity = self.session.query(func.max(Activity.start_date)).scalar() 83 | if last_activity: 84 | last_activity_date = arrow.get(last_activity) 85 | last_activity_date = last_activity_date.shift(days=-7) 86 | filters = {"after": last_activity_date.datetime} 87 | else: 88 | filters = {} 89 | 90 | print("Start syncing") 91 | for strava_activity in self.client.get_activities(**filters): 92 | created = update_or_create_activity(self.session, athlete, strava_activity) 93 | if created: 94 | sys.stdout.write("+") 95 | else: 96 | sys.stdout.write(".") 97 | sys.stdout.flush() 98 | 99 | self.session.commit() 100 | 101 | def load(self) -> Optional[Tuple[Dict, List[Dict], List[Dict]]]: 102 | athlete = self.session.query(Athlete).first() 103 | if athlete is None: 104 | return None 105 | activities = self.session.query(Activity).filter_by(athlete_id=athlete.id).order_by(Activity.start_date_local) 106 | 107 | athlete_dict = athlete.to_dict() 108 | activity_list = [] 109 | 110 | streak = 0 111 | last_date = None 112 | for activity in activities: 113 | # Determine running streak. 114 | if activity.type == "Run": 115 | if activity.start_date_local is None: 116 | date = None 117 | streak = 0 118 | else: 119 | date = datetime.datetime.strptime(activity.start_date_local, "%Y-%m-%d %H:%M:%S").date() 120 | if last_date is None: 121 | streak = 1 122 | elif date == last_date: 123 | pass 124 | elif date == last_date + datetime.timedelta(days=1): 125 | streak += 1 126 | else: 127 | assert date > last_date 128 | streak = 1 129 | activity.set_streak(streak) 130 | last_date = date 131 | # Determine visited POIs. 132 | activity.set_pois(self.pois) 133 | # Append to result list. 134 | activity_list.append(activity.to_dict()) 135 | 136 | pois_list = [] 137 | if self.pois: 138 | pois_list = [ 139 | {"name": name, "lat": point["lat"], "lon": point["lon"]} for (name, point) in self.pois.items() 140 | ] 141 | 142 | return athlete_dict, activity_list, pois_list 143 | -------------------------------------------------------------------------------- /activities/generator/db.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any, Dict, List, Optional, Tuple 3 | from decimal import Decimal 4 | 5 | from geopy import distance as geopy_distance # type: ignore 6 | import polyline # type: ignore 7 | from sqlalchemy import ( 8 | create_engine, 9 | Column, 10 | DateTime, 11 | Float, 12 | ForeignKey, 13 | Integer, 14 | Interval, 15 | PickleType, 16 | String, 17 | ) 18 | from sqlalchemy.ext.declarative import declarative_base 19 | from sqlalchemy.orm import relationship, sessionmaker, Session 20 | from stravalib.model import Activity as StravaActivity # type: ignore 21 | import stravalib.unithelper # type: ignore 22 | 23 | from activities.generator.valuerange import ValueRange 24 | 25 | 26 | Base = declarative_base() 27 | 28 | 29 | class Auth(Base): 30 | __tablename__ = "auth" 31 | 32 | access_token = Column(String, primary_key=True) 33 | refresh_token = Column(String) 34 | expires_at = Column(DateTime) 35 | 36 | 37 | class Athlete(Base): 38 | __tablename__ = "athletes" 39 | 40 | id = Column(Integer, primary_key=True) 41 | firstname = Column(String) 42 | lastname = Column(String) 43 | 44 | def to_dict(self) -> Dict: 45 | return {"id": self.id, "firstname": self.firstname, "lastname": self.lastname} 46 | 47 | 48 | def is_point_on_track( 49 | point: Tuple[float, float], track: List[Tuple[float, float]], max_distance_meters: float = 100 50 | ) -> bool: 51 | point_lat, point_lon = point 52 | for coordinates in track: 53 | lat, lon = coordinates 54 | is_on_track = ( 55 | abs(point_lat - lat) < 0.01 56 | and abs(point_lon - lon) < 0.01 57 | and geopy_distance.geodesic(point, coordinates).meters < max_distance_meters 58 | ) 59 | 60 | if is_on_track: 61 | return True 62 | return False 63 | 64 | 65 | def unit_m(d: Optional[Any]) -> Optional[Decimal]: 66 | if d is None: 67 | return None 68 | return Decimal(float(stravalib.unithelper.meters(d))) 69 | 70 | 71 | def unit_m_per_s(d: Optional[Any]) -> Optional[Decimal]: 72 | if d is None: 73 | return None 74 | return Decimal(float(stravalib.unithelper.meters_per_second(d))) 75 | 76 | 77 | def unit_float(d: Optional[Any]) -> Optional[Decimal]: 78 | if d is None: 79 | return None 80 | return Decimal(float(d)) 81 | 82 | 83 | def unit_dt(d: Optional[Any]) -> Optional[datetime.timedelta]: 84 | if d is None: 85 | return None 86 | return datetime.timedelta(d) 87 | 88 | 89 | ACTIVITY_KEYS = [ 90 | "strava_id", 91 | "athlete_id", 92 | "name", 93 | "distance", 94 | "moving_time", 95 | "elapsed_time", 96 | "total_elevation_gain", 97 | "type", 98 | "start_date", 99 | "start_date_local", 100 | "location_country", 101 | "summary_polyline", 102 | "average_heartrate", 103 | "average_speed", 104 | ] 105 | 106 | 107 | class Activity(Base): 108 | __tablename__ = "activities" 109 | 110 | strava_id = Column(Integer, primary_key=True) 111 | athlete_id = Column(Integer, ForeignKey("athletes.id")) 112 | athlete = relationship("Athlete") 113 | name = Column(String) 114 | distance = Column(Float) 115 | moving_time = Column(Interval) 116 | elapsed_time = Column(Interval) 117 | total_elevation_gain = Column(Float) 118 | type = Column(String) 119 | start_date = Column(String) 120 | start_date_local = Column(String) 121 | location_country = Column(String) 122 | summary_polyline = Column(String) 123 | track = Column(PickleType) 124 | average_heartrate = Column(Float) 125 | average_speed = Column(Float) 126 | pois = None 127 | streak = 0 128 | 129 | def set_streak(self, streak: int) -> None: 130 | self.streak = streak 131 | 132 | def bbox(self) -> Tuple[Optional[ValueRange], Optional[ValueRange]]: 133 | if self.track: 134 | lat_range = ValueRange() 135 | lon_range = ValueRange() 136 | for (lat, lon) in self.track: 137 | lat_range.add(lat) 138 | lon_range.add(lon) 139 | return lat_range, lon_range 140 | return None, None 141 | 142 | def set_pois(self, pois: Dict[str, Dict]) -> None: 143 | if self.track and pois: 144 | lat_range, lon_range = self.bbox() 145 | if lat_range is None or lon_range is None: 146 | return 147 | track_pois = [] 148 | for (name, point) in pois.items(): 149 | lat, lon = point["lat"], point["lon"] 150 | if not lat_range.contains(lat, 0.01) or not lon_range.contains(lon, 0.01): 151 | continue 152 | if is_point_on_track((lat, lon), self.track): 153 | track_pois.append(name) 154 | 155 | if track_pois: 156 | self.pois = track_pois 157 | return 158 | 159 | def to_dict(self) -> Dict: 160 | out: Dict[str, Any] = {} 161 | for key in ACTIVITY_KEYS: 162 | attr = getattr(self, key) 163 | if attr is None: 164 | out[key] = attr 165 | elif isinstance(attr, (datetime.timedelta, datetime.datetime)): 166 | out[key] = str(attr) 167 | elif isinstance(attr, (float, int)): 168 | out[key] = attr 169 | else: 170 | out[key] = str(attr) 171 | 172 | if self.pois: 173 | out["pois"] = self.pois 174 | if self.streak is not None: 175 | out["streak"] = self.streak 176 | 177 | return out 178 | 179 | 180 | def update_or_create_activity(session: Session, athlete: Athlete, strava_activity: StravaActivity) -> bool: 181 | created = False 182 | activity: Optional[Activity] = session.query(Activity).filter_by(strava_id=strava_activity.id).first() 183 | if not activity: 184 | activity = Activity( 185 | strava_id=strava_activity.id, 186 | athlete=athlete, 187 | name=strava_activity.name, 188 | distance=unit_m(strava_activity.distance), 189 | moving_time=strava_activity.moving_time, 190 | elapsed_time=strava_activity.elapsed_time, 191 | total_elevation_gain=unit_m(strava_activity.total_elevation_gain), 192 | type=strava_activity.type, 193 | start_date=strava_activity.start_date, 194 | start_date_local=strava_activity.start_date_local, 195 | location_country=strava_activity.location_country, 196 | average_heartrate=unit_float(strava_activity.average_heartrate), 197 | average_speed=unit_m_per_s(strava_activity.average_speed), 198 | ) 199 | session.add(activity) 200 | created = True 201 | else: 202 | activity.name = strava_activity.name 203 | activity.distance = unit_m(strava_activity.distance) 204 | activity.moving_time = strava_activity.moving_time 205 | activity.elapsed_time = strava_activity.elapsed_time 206 | activity.total_elevation_gain = unit_m(strava_activity.total_elevation_gain) 207 | activity.type = strava_activity.type 208 | activity.average_heartrate = unit_float(strava_activity.average_heartrate) 209 | activity.average_speed = unit_m_per_s(strava_activity.average_speed) 210 | try: 211 | decoded = polyline.decode(strava_activity.map.summary_polyline) 212 | activity.summary_polyline = strava_activity.map.summary_polyline 213 | if decoded: 214 | activity.track = decoded 215 | except (AttributeError, TypeError): 216 | pass 217 | 218 | return created 219 | 220 | 221 | def clear_activities(session: Session) -> None: 222 | session.query(Activity).delete() 223 | 224 | 225 | def init_db(db_path: str) -> Session: 226 | engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False}) 227 | Base.metadata.create_all(engine) 228 | session = sessionmaker(bind=engine) 229 | return session() 230 | -------------------------------------------------------------------------------- /activities/generator/valuerange.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class ValueRange: 5 | def __init__(self) -> None: 6 | self._min: Optional[float] = None 7 | self._max: Optional[float] = None 8 | 9 | def __str__(self) -> str: 10 | if self.empty(): 11 | return "[]" 12 | if self.singleton(): 13 | return f"[{self.min()}]" 14 | return f"[{self.min()}, {self.max()}]" 15 | 16 | def empty(self) -> bool: 17 | return self._min is None 18 | 19 | def singleton(self) -> bool: 20 | return self._min == self._max 21 | 22 | def min(self) -> Optional[float]: 23 | return self._min 24 | 25 | def max(self) -> Optional[float]: 26 | return self._max 27 | 28 | def add(self, value: float) -> None: 29 | if self.empty(): 30 | self._min = value 31 | self._max = value 32 | return 33 | 34 | assert self._min is not None 35 | assert self._max is not None 36 | 37 | if value < self._min: 38 | self._min = value 39 | elif value > self._max: 40 | self._max = value 41 | 42 | def adjust(self, delta_min: float, delta_max: float) -> None: 43 | if self.empty(): 44 | return 45 | 46 | assert self._min is not None 47 | assert self._max is not None 48 | 49 | self._min += delta_min 50 | self._max += delta_max 51 | if self._min > self._max: 52 | self._min = None 53 | self._max = None 54 | 55 | def contains(self, value: float, slack: float = 0) -> bool: 56 | if self.empty(): 57 | return False 58 | 59 | assert self._min is not None 60 | assert self._max is not None 61 | 62 | return self._min - slack <= value <= self._max + slack 63 | -------------------------------------------------------------------------------- /activities/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import datetime 4 | import json 5 | import os 6 | 7 | import click 8 | 9 | import activities.auth.flask_app as auth_app 10 | from activities.generator import Generator 11 | 12 | 13 | HTTP_PORT = 5000 14 | 15 | 16 | def run_auth_app(config: str, data: str, pois: str) -> None: 17 | # Run a simple web server to get authentication data to run the sync process 18 | # Read from config.json file and output to account.json 19 | auth_app.configure(config, data, pois) 20 | click.launch(f"http://127.0.0.1:{HTTP_PORT}/") 21 | auth_app.app.run(port=HTTP_PORT, debug=True) 22 | 23 | 24 | @click.command() 25 | @click.option("-c", "--config", default="config.json", metavar="JSON_FILE", type=click.Path(exists=True)) 26 | @click.option("-r", "--reset", is_flag=True, help="Reset database.") 27 | @click.option("-a", "--auth", is_flag=True, help="Authenticate with Strava.") 28 | @click.option("-s", "--sync", is_flag=True, help="Sync activities.") 29 | @click.option("-p", "--pois", metavar="JSON_FILE", type=click.Path()) 30 | @click.option("-d", "--data", default="data.db", metavar="DATA_FILE", type=click.Path()) 31 | @click.option("-o", "--output", default="web/activities.js", metavar="JS_FILE", type=click.Path()) 32 | @click.option("-b", "--browser", is_flag=True, help="Open the generated website in a web browser.") 33 | @click.option("-f", "--force", is_flag=True, help="Force re-sync of all activities.") 34 | def run( 35 | config: str, 36 | pois: str, 37 | data: str, 38 | output: str, 39 | sync: bool, 40 | browser: bool, 41 | force: bool, 42 | reset: bool, 43 | auth: bool, 44 | ) -> None: 45 | 46 | # Drop DB if reset option is set 47 | if reset: 48 | os.remove(data) 49 | return 50 | 51 | if auth: 52 | run_auth_app(config, data, pois) 53 | return 54 | 55 | generator = Generator(config, data, pois) 56 | 57 | if sync: 58 | generator.sync(force) 59 | 60 | loaded = generator.load() 61 | if loaded is None: 62 | raise Exception("failed to load athlete") 63 | athlete, activities_list, pois_list = loaded 64 | with open(output, "w", encoding="utf-8") as f: 65 | now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 66 | f.write(f"const the_last_sync = '{now}';\n") 67 | 68 | f.write("const the_strava_athlete = ") 69 | json.dump(athlete, f, indent=2) 70 | f.write(";\n") 71 | 72 | f.write("const the_activities = ") 73 | json.dump(activities_list, f, indent=2) 74 | f.write(";\n") 75 | 76 | f.write("const the_pois = ") 77 | json.dump(pois_list, f, indent=2) 78 | f.write(";\n") 79 | 80 | if browser: 81 | click.launch("web/index.html") 82 | 83 | 84 | if __name__ == "__main__": 85 | run() 86 | -------------------------------------------------------------------------------- /config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "client_id": "YOUR STRAVA API CLIENT ID, e.g. 12345", 3 | "client_secret": "YOUR STRAVA API CLIENT SECRET, e.g. f94a536071f0e473d18e8ad6864a8d547126d3e5" 4 | } 5 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = sqlmypy 3 | check_untyped_defs = True 4 | disallow_incomplete_defs = True 5 | disallow_untyped_defs = True -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | mypy 3 | pylint 4 | sqlalchemy-stubs 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | flask 3 | geopy 4 | polyline 5 | sqlalchemy 6 | stravalib 7 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flopp/activities/f0bcf06e071123b437e7cb667116c4f120f8c913/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import setuptools 4 | 5 | 6 | def _read_reqs(relpath): 7 | abspath = os.path.join(os.path.dirname(__file__), relpath) 8 | with open(abspath) as f: 9 | return [ 10 | s.strip() 11 | for s in f.readlines() 12 | if s.strip() and not s.strip().startswith("#") 13 | ] 14 | 15 | 16 | setuptools.setup( 17 | name="activities", 18 | version="0.1.0", 19 | install_requires=_read_reqs("requirements.txt"), 20 | extras_require={"dev": _read_reqs("requirements-dev.txt"),}, 21 | entry_points={"console_scripts": ["activities = activities.run:run",],}, 22 | packages=setuptools.find_packages(), 23 | include_package_data=True, 24 | ) 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = format 3 | 4 | 5 | [testenv:format] 6 | basepython=python3.9 7 | deps = -r requirements-dev.txt 8 | commands = 9 | black activities -l 120 --check --diff 10 | pylint activities 11 | mypy activities 12 | -------------------------------------------------------------------------------- /web/app.js: -------------------------------------------------------------------------------- 1 | /* global L, HeatmapOverlay, noUiSlider, the_activities, the_strava_athlete, the_pois, the_last_sync */ 2 | 3 | $(function() { 4 | App.init( 5 | (typeof the_activities !== 'undefined') ? the_activities : [], 6 | (typeof the_strava_athlete !== 'undefined') ? the_strava_athlete : null, 7 | (typeof the_pois !== 'undefined') ? the_pois : [], 8 | (typeof the_last_sync !== 'undefined') ? the_last_sync : "n/a" 9 | ); 10 | }); 11 | 12 | var App = { 13 | init: function (activities, athlete, pois, last_sync) { 14 | this.activities = activities.reverse(); 15 | this.added_activities = 0; 16 | this.athlete = athlete; 17 | this.pois = pois; 18 | this.filter_name = null; 19 | this.filter_type = null; 20 | this.filter_category = null; 21 | this.filter_year = null; 22 | this.filter_min_distance = 0; 23 | this.filter_max_distance = null; 24 | this.max_distance = null; 25 | this.selected_activity = null; 26 | this.heatmapActive = false; 27 | this.heatmapDataInitialized = false; 28 | this.map = this.initMap(); 29 | this.track = null; 30 | this.start_point = null; 31 | this.end_point = null; 32 | this.initEventHandlers(); 33 | 34 | this.start_icon = L.BeautifyIcon.icon({ 35 | icon: 'play-circle', 36 | iconShape: 'circle', 37 | borderColor: 'green', 38 | textColor: 'green' 39 | }); 40 | this.end_icon = L.BeautifyIcon.icon({ 41 | icon: 'stop-circle', 42 | iconShape: 'circle', 43 | borderColor: 'red', 44 | textColor: 'red' 45 | }); 46 | this.poi_icon = L.BeautifyIcon.icon({ 47 | icon: 'star', 48 | iconShape: 'circle', 49 | borderColor: 'blue', 50 | textColor: 'blue' 51 | }); 52 | 53 | var activity_id = null; 54 | const regex = /^#(\d+)$/; 55 | const match = window.location.hash.match(regex); 56 | if (match && this.hasActivity(match[1])) { 57 | activity_id = match[1]; 58 | } else if (this.activities.length > 0) { 59 | activity_id = this.activities[0]['strava_id']; 60 | } 61 | this.populatePois(); 62 | this.populateFilters(); 63 | this.filter_max_distance = this.max_distance; 64 | this.populateActivities(activity_id); 65 | this.toggleSidebar("sidebar-activities"); 66 | this.loadActivity(activity_id); 67 | this.filterActivities('', 'All', 'All', 'All', 0, this.max_distance); 68 | document.querySelector("#no-activities-message").style.display = "none"; 69 | $('#last-sync').text(`Last Sync: ${last_sync}`); 70 | if (this.athlete) { 71 | document.querySelector("#strava-button").href = `https://www.strava.com/athletes/${this.athlete["id"]}`; 72 | } 73 | 74 | }, 75 | 76 | initMap: function() { 77 | const self = this; 78 | 79 | const openstreetmap = L.tileLayer('https://{s}.tile.osm.org/{z}/{x}/{y}.png', { 80 | attribution: 'map data: © OpenStreetMap contributors'}); 81 | const opentopomap = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { 82 | attribution: 'map data: © OpenStreetMap contributors, SRTM | map style: © OpenTopoMap (CC-BY-SA)'}); 83 | const cartodark = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/dark_all/{z}/{x}/{y}.png', { 84 | attribution: 'map data: © OpenStreetMap contributors, SRTM | map style: © OpenTopoMap (CC-BY-SA)'}); 85 | const arcgis_worldimagery = L.tileLayer('https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { 86 | attribution: 'Source: Esri, Maxar, GeoEye, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community'}); 87 | 88 | const heatmap_config = { 89 | // radius should be small ONLY if scaleRadius is true (or small radius is intended) 90 | // if scaleRadius is false it will be the constant radius used in pixels 91 | "radius": 0.0007, 92 | "maxOpacity": .5, 93 | // scales the radius based on map zoom 94 | "scaleRadius": true, 95 | "useLocalExtrema": false, 96 | latField: 'lat', 97 | lngField: 'lng', 98 | valueField: 'count' 99 | }; 100 | this.heatmap = new HeatmapOverlay(heatmap_config); 101 | 102 | const map = L.map('map', { 103 | center: [48, 8], 104 | zoom: 13, 105 | layers: [openstreetmap], 106 | zoomSnap: 0.33 107 | }); 108 | 109 | L.control.layers({ 110 | "OpenStreetMap": openstreetmap, 111 | "OpenTopoMap": opentopomap, 112 | "CARTO dark": cartodark, 113 | "ArcGIS World Imagery": arcgis_worldimagery, 114 | }, { 115 | "Heatmap": this.heatmap, 116 | }).addTo(map); 117 | map.zoomControl.setPosition('bottomright'); 118 | 119 | map.on('overlayadd', event => { 120 | if (event.name === "Heatmap") { 121 | self.onHeatmapToggle(true); 122 | } 123 | }); 124 | map.on('overlayremove', event => { 125 | if (event.name === "Heatmap") { 126 | self.onHeatmapToggle(false); 127 | } 128 | }); 129 | 130 | return map; 131 | }, 132 | 133 | initEventHandlers: function() { 134 | var self = this; 135 | /* 136 | * Add click event handlers for objects of class 'activity', 'type', or 137 | * 'category' that are created in the future. 138 | */ 139 | document.addEventListener('click', event => { 140 | var obj = event.target; 141 | while (obj.parentElement !== null && 142 | !obj.classList.contains('activity') && 143 | !obj.classList.contains('type') && 144 | !obj.classList.contains('category') && 145 | !obj.classList.contains('year') 146 | ) { 147 | obj = obj.parentElement; 148 | } 149 | 150 | if (obj.classList.contains('activity')) { 151 | self.clickActivity(obj.dataset.id); 152 | event.stopPropagation(); 153 | } else if (obj.classList.contains('type')) { 154 | self.setTypeFilter(obj.dataset.type); 155 | event.stopPropagation(); 156 | } else if (obj.classList.contains('category')) { 157 | self.setCategoryFilter(obj.dataset.category); 158 | event.stopPropagation(); 159 | } else if (obj.classList.contains('year')) { 160 | self.setCategoryFilter(obj.dataset.year); 161 | event.stopPropagation(); 162 | } 163 | }, false); 164 | $("#filter-name").change(function () { 165 | self.setNameFilter($(this).val()); 166 | }); 167 | $("#filter-type") 168 | .change(function () { 169 | var type = null; 170 | $("#filter-type option:selected").each(function() { 171 | type = $(this).val(); 172 | }); 173 | self.setTypeFilter(type); 174 | }); 175 | $("#filter-category") 176 | .change(function () { 177 | var category = null; 178 | $("#filter-category option:selected").each(function() { 179 | category = $(this).val(); 180 | }); 181 | self.setCategoryFilter(category); 182 | }); 183 | $("#filter-year") 184 | .change(function () { 185 | var year = null; 186 | $("#filter-year option:selected").each(function() { 187 | year = $(this).val(); 188 | }); 189 | self.setYearFilter(year); 190 | }); 191 | 192 | $("#prev-button").click(function() { 193 | self.loadPrevActivity(); 194 | }); 195 | $("#next-button").click(function() { 196 | self.loadNextActivity(); 197 | }); 198 | 199 | document.querySelectorAll(".statistics-button").forEach(button => { 200 | button.onclick = () => { 201 | self.loadActivity(button.dataset.id); 202 | } 203 | }); 204 | 205 | document.querySelectorAll(".sidebar-control").forEach(control => { 206 | const container_id = control.dataset.container; 207 | const container = document.getElementById(container_id); 208 | 209 | container.querySelector(".header > .close").onclick = () => { 210 | self.toggleSidebar(null); 211 | }; 212 | 213 | control.querySelector("a").onclick = () => { 214 | self.toggleSidebar(container_id); 215 | }; 216 | }); 217 | }, 218 | 219 | populatePois: function() { 220 | const self = this; 221 | if (!this.pois) { 222 | return; 223 | } 224 | 225 | this.pois.forEach(poi => { 226 | L.marker(L.latLng(poi["lat"], poi["lon"]), {icon: self.poi_icon, title: poi["name"]}).addTo(self.map); 227 | }); 228 | }, 229 | 230 | populateFilters: function() { 231 | const self = this; 232 | var types = new Set(); 233 | var categories = new Set(); 234 | var years = new Set(); 235 | this.max_distance = 0; 236 | 237 | this.activities.forEach(activity => { 238 | self.max_distance = Math.max(activity['distance'], self.max_distance); 239 | types.add(activity['type']); 240 | years.add(activity['start_date_local'].slice(0, 4)); 241 | if ('pois' in activity) { 242 | activity['pois'].forEach(category => { 243 | categories.add(category); 244 | }); 245 | } 246 | }); 247 | this.max_distance = Math.ceil(this.max_distance / 1000.0); 248 | 249 | $('#filter-type').append( 250 | $('') 251 | ); 252 | Array.from(types).sort().forEach(type => { 253 | $('#filter-type').append( 254 | $(``) 255 | ); 256 | }); 257 | $('#filter-category').append( 258 | $('') 259 | ); 260 | Array.from(categories).sort().forEach(category => { 261 | $('#filter-category').append( 262 | $(``) 263 | ); 264 | }); 265 | $('#filter-year').append( 266 | $('') 267 | ); 268 | Array.from(years).sort().forEach(year => { 269 | $('#filter-year').append( 270 | $(``) 271 | ); 272 | }); 273 | 274 | const distance_slider = document.getElementById('filter-distance'); 275 | noUiSlider.create(distance_slider, { 276 | start: [0, self.max_distance], 277 | connect: true, 278 | range: { 279 | 'min': 0, 280 | 'max': self.max_distance 281 | }, 282 | format: { 283 | to: function (value) { 284 | return value.toFixed(0); 285 | }, 286 | from: function (value) { 287 | return Number(value); 288 | } 289 | }, 290 | step: 1, 291 | tooltips: true 292 | }); 293 | distance_slider.noUiSlider.on('set', function () { 294 | const range = this.get(); 295 | self.setDistanceFilter(Number(range[0]), Number(range[1])); 296 | }); 297 | }, 298 | 299 | displayPolyline: function(polyline) { 300 | if (this.track) { 301 | this.track.remove(); 302 | this.track = null; 303 | } 304 | if (this.start_point) { 305 | this.start_point.remove(); 306 | this.start_point = null; 307 | } 308 | if (this.end_point) { 309 | this.end_point.remove(); 310 | this.end_point = null; 311 | } 312 | if (polyline !== null && polyline !== '') { 313 | const decoded = L.PolylineUtil.decode(polyline); 314 | this.track = L.polyline(decoded, { 315 | color: 'red', 316 | distanceMarkers: {iconSize: [20, 14] }, 317 | }).addTo(this.map); 318 | if (decoded.length > 0) { 319 | this.start_point = L.marker(decoded[0], {icon: this.start_icon}).addTo(this.map); 320 | this.end_point = L.marker(decoded.slice(-1)[0], {icon: this.end_icon}).addTo(this.map); 321 | } 322 | this.map.fitBounds(this.track.getBounds(), {padding: [64, 64]}); 323 | } 324 | }, 325 | 326 | hasActivity: function(id) { 327 | return this.getActivity(id) !== undefined; 328 | }, 329 | 330 | getActivity: function(id) { 331 | return this.activities.find(activity => { 332 | return activity['strava_id'] == id; 333 | }); 334 | }, 335 | 336 | clickActivity: function(id) { 337 | if (this.selected_activity === id) { 338 | this.loadActivity(null); 339 | } else { 340 | this.loadActivity(id); 341 | } 342 | }, 343 | 344 | loadActivity: function(id) { 345 | this.selected_activity = id; 346 | document.querySelectorAll('.activity').forEach(div => { 347 | div.classList.remove('is-info'); 348 | }); 349 | if (id === null) { 350 | window.location.hash = "#"; 351 | this.displayPolyline(null); 352 | } else { 353 | window.location.hash = `#${id}`; 354 | const activity_div = document.querySelector(`.activity[data-id="${id}"]`); 355 | activity_div.classList.add('is-info'); 356 | activity_div.scrollIntoView({behavior: "smooth", block: "nearest", inline: "nearest"}); 357 | var polyline = null; 358 | const activity = this.getActivity(id); 359 | if (activity !== undefined) { 360 | if ('summary_polyline' in activity) { 361 | polyline = activity['summary_polyline']; 362 | } 363 | $("#activity-name").text(activity['name']); 364 | $("#activity-date").text(activity['start_date_local'].replace('T', ' ')); 365 | $("#activity-distance").text(this.format_distance(activity['distance'])); 366 | $("#activity-time").text(activity['moving_time']); 367 | } 368 | this.displayPolyline(polyline); 369 | } 370 | }, 371 | 372 | loadPrevActivity: function() { 373 | const self = this; 374 | var load = null; 375 | var found = false; 376 | 377 | this.activities.forEach(activity => { 378 | if (!self.matchesFilter(activity)) { 379 | return; 380 | } 381 | const id = activity['strava_id']; 382 | if (!found) { 383 | if (id == self.selected_activity) { 384 | found = true; 385 | } 386 | } else if (load === null) { 387 | load = id; 388 | } 389 | }); 390 | 391 | if (load !== null && found) { 392 | this.loadActivity(load); 393 | } 394 | }, 395 | 396 | loadNextActivity: function() { 397 | const self = this; 398 | var load = null; 399 | var found = false; 400 | 401 | this.activities.forEach(activity => { 402 | if (!self.matchesFilter(activity)) { 403 | return; 404 | } 405 | const id = activity['strava_id']; 406 | if (!found) { 407 | if (id == self.selected_activity) { 408 | found = true; 409 | } else { 410 | load = id; 411 | } 412 | } 413 | }); 414 | 415 | if (load !== null && found) { 416 | this.loadActivity(load); 417 | } 418 | }, 419 | 420 | matchesFilter: function(activity) { 421 | if (this.filter_name !== null && this.filter_name !== '') { 422 | if (!activity['name'].toLowerCase().includes(this.filter_name.toLowerCase())) { 423 | return false; 424 | } 425 | } 426 | if (this.filter_type !== null && this.filter_type !== 'All') { 427 | if (activity['type'] !== this.filter_type) { 428 | return false; 429 | } 430 | } 431 | if (this.filter_year !== null && this.filter_year !== 'All') { 432 | if (activity['start_date_local'].slice(0, 4) !== this.filter_year) { 433 | return false; 434 | } 435 | } 436 | if (this.filter_category !== null && this.filter_category !== 'All') { 437 | if (!('pois' in activity) || !(activity['pois'].includes(this.filter_category))) { 438 | return false; 439 | } 440 | } 441 | const distance = activity['distance'] / 1000.0; 442 | if (distance < this.filter_min_distance || distance > this.filter_max_distance) { 443 | return false; 444 | } 445 | 446 | return true; 447 | }, 448 | 449 | setNameFilter: function(name) { 450 | this.filterActivities( 451 | name, 452 | this.filter_type, 453 | this.filter_category, 454 | this.filter_year, 455 | this.filter_min_distance, 456 | this.filter_max_distance); 457 | }, 458 | 459 | setTypeFilter: function(type) { 460 | this.filterActivities( 461 | this.filter_name, 462 | type, 463 | this.filter_category, 464 | this.filter_year, 465 | this.filter_min_distance, 466 | this.filter_max_distance); 467 | }, 468 | 469 | setYearFilter: function(year) { 470 | this.filterActivities( 471 | this.filter_name, 472 | this.filter_type, 473 | this.filter_category, 474 | year, 475 | this.filter_min_distance, 476 | this.filter_max_distance); 477 | }, 478 | 479 | setCategoryFilter: function(category) { 480 | this.filterActivities( 481 | this.filter_name, 482 | this.filter_type, 483 | category, 484 | this.filter_year, 485 | this.filter_min_distance, 486 | this.filter_max_distance); 487 | }, 488 | 489 | setDistanceFilter: function(min_distance, max_distance) { 490 | this.filterActivities( 491 | this.filter_name, 492 | this.filter_type, 493 | this.filter_category, 494 | this.filter_year, 495 | min_distance, 496 | max_distance); 497 | }, 498 | 499 | filterActivities: function(name, type, category, year, min_distance, max_distance) { 500 | if (name === this.filter_name && 501 | type === this.filter_type && 502 | category === this.filter_category && 503 | year === this.filter_year && 504 | min_distance === this.filter_min_distance && 505 | max_distance === this.filter_max_distance 506 | ) { 507 | return; 508 | } 509 | this.filter_name = name; 510 | this.filter_type = type; 511 | this.filter_category = category; 512 | this.filter_year = year; 513 | this.filter_min_distance = min_distance; 514 | this.filter_max_distance = max_distance; 515 | document.querySelector('#filter-name').value = name; 516 | document.querySelector(`#filter-type [value="${type}"]`).selected = true; 517 | document.querySelector(`#filter-category [value="${category}"]`).selected = true; 518 | document.querySelector(`#filter-year [value="${year}"]`).selected = true; 519 | document.getElementById('filter-distance').noUiSlider.set([min_distance, max_distance]); 520 | var self = this; 521 | var first_activity = null; 522 | var selected_found = false; 523 | 524 | var count = 0; 525 | var distance_sum = 0; 526 | var distance_max = 0; 527 | var distance_max_id = null; 528 | var elevation_sum = 0; 529 | var elevation_max = 0; 530 | var elevation_max_id = null; 531 | var time_sum = 0; 532 | var time_max = 0; 533 | var time_max_id = null; 534 | const types = new Map(); 535 | this.activities.forEach(item => { 536 | const activity_id = item['strava_id'] 537 | const div = document.querySelector(`.activity[data-id="${activity_id}"]`); 538 | const match = self.matchesFilter(item); 539 | 540 | if (div) { 541 | div.style.display = match ? "block" : "none"; 542 | } 543 | if (match) { 544 | if (!first_activity) { 545 | first_activity = activity_id; 546 | } 547 | if (self.selected_activity == activity_id) { 548 | selected_found = true; 549 | } 550 | count += 1; 551 | 552 | if (types.has(item['type'])) { 553 | types.set(item['type'], types.get(item['type']) + 1); 554 | } else { 555 | types.set(item['type'], 1); 556 | } 557 | 558 | distance_sum += item['distance']; 559 | if (distance_max_id === null || item['distance'] > distance_max) { 560 | distance_max_id = activity_id; 561 | distance_max = item['distance']; 562 | } 563 | 564 | elevation_sum += item['total_elevation_gain']; 565 | if (elevation_max_id === null || item['total_elevation_gain'] > elevation_max) { 566 | elevation_max_id = activity_id; 567 | elevation_max = item['total_elevation_gain']; 568 | } 569 | 570 | const time = this.parse_duration(item['moving_time']) 571 | time_sum += time; 572 | if (time_max_id === null || time > time_max) { 573 | time_max_id = activity_id; 574 | time_max = time; 575 | } 576 | } 577 | }); 578 | 579 | this.statistics_distance_max_id = distance_max_id; 580 | this.statistics_elevation_max_id = elevation_max_id; 581 | this.statistics_time_max_id = time_max_id; 582 | 583 | document.querySelector('#filter-matches').textContent = `${count} / ${this.activities.length}`; 584 | document.querySelector('#no-activities-message').style.display = (count === 0) ? "block" : "none"; 585 | 586 | document.querySelector('#statistics-count').textContent = `${count}`; 587 | if (count > 0) { 588 | document.querySelectorAll('.statistics-table-type-row').forEach(row => { 589 | row.remove(); 590 | }); 591 | var before = document.querySelector('#statistics-table-types').nextElementSibling; 592 | types.forEach((value, key) => { 593 | const tr = document.createElement("tr"); 594 | tr.classList.add("statistics-table-type-row"); 595 | const td1 = document.createElement("td"); 596 | td1.appendChild(document.createTextNode(key)); 597 | tr.appendChild(td1); 598 | const td2 = document.createElement("td"); 599 | td2.colSpan = 2; 600 | td2.appendChild(document.createTextNode(value)); 601 | tr.appendChild(td2); 602 | before.parentNode.insertBefore(tr, before); 603 | }); 604 | document.querySelector('#statistics-distance-max-button').dataset.id = distance_max_id; 605 | document.querySelector('#statistics-elevation-max-button').dataset.id = elevation_max_id; 606 | document.querySelector('#statistics-time-max-button').dataset.id = time_max_id; 607 | 608 | document.querySelector('#statistics-distance-sum').textContent = this.format_distance(distance_sum); 609 | document.querySelector('#statistics-distance-avg').textContent = this.format_distance(distance_sum / count); 610 | document.querySelector('#statistics-distance-max').textContent = this.format_distance(distance_max); 611 | 612 | document.querySelector('#statistics-elevation-sum').textContent = this.format_elevation(elevation_sum); 613 | document.querySelector('#statistics-elevation-avg').textContent = this.format_elevation(elevation_sum / count); 614 | document.querySelector('#statistics-elevation-max').textContent = this.format_elevation(elevation_max); 615 | 616 | document.querySelector('#statistics-time-sum').textContent = this.format_duration(time_sum); 617 | document.querySelector('#statistics-time-avg').textContent = this.format_duration(time_sum / count); 618 | document.querySelector('#statistics-time-max').textContent = this.format_duration(time_max); 619 | 620 | document.querySelector('#statistics-table').style.display = "table"; 621 | } else { 622 | document.querySelector('#statistics-table').style.display = "none"; 623 | } 624 | 625 | if (selected_found) { 626 | this.loadActivity(this.selected_activity); 627 | } else { 628 | this.loadActivity(first_activity); 629 | } 630 | 631 | this.fillHeatmap(); 632 | }, 633 | 634 | format_distance: function (d) { 635 | return `${(d / 1000.0).toFixed(2)} km`; 636 | }, 637 | 638 | format_elevation: function (d) { 639 | return `${d.toFixed(0)} m`; 640 | }, 641 | 642 | parse_duration: function (s) { 643 | var a = /^(\d+):(\d\d):(\d\d)$/.exec(s); 644 | if (a === null) { 645 | console.log("Failed to parse duration:", s); 646 | return 0; 647 | } 648 | return 3600 * parseInt(a[1]) + 60 * parseInt(a[2]) + parseInt(a[3]); 649 | }, 650 | 651 | format_duration: function (d) { 652 | const secondsPerDay = 24 * 60 * 60; 653 | if (d < secondsPerDay) { 654 | return new Date(d * 1000).toISOString().substr(11, 8); 655 | } else { 656 | const days = Math.floor(d / secondsPerDay); 657 | return `${days}d ${new Date((d - days * secondsPerDay) * 1000).toISOString().substr(11, 8)}`; 658 | } 659 | }, 660 | 661 | format_pace: function (d) { 662 | const pace = (1000.0 / 60.0) * (1.0 / d); 663 | const minutes = Math.floor(pace); 664 | const seconds = Math.floor((pace - minutes) * 60.0); 665 | return `${minutes}:${seconds.toFixed(0).toString().padStart(2, "0")} min/km`; 666 | }, 667 | 668 | format_heartrate: function (d) { 669 | return `${d.toFixed(0)} bpm`; 670 | }, 671 | 672 | populateActivities: function(search_id) { 673 | var self = this; 674 | 675 | // initially load 20 activities or enough activities until 'search_id' is found. 676 | var init = (self.added_activities === 0); 677 | var count = 0; 678 | var idFound = false; 679 | this.activities.forEach(activity => { 680 | const activity_id = activity['strava_id']; 681 | const div = document.querySelector(`.activity[data-id="${activity_id}"]`); 682 | if (div) { 683 | return; 684 | } 685 | if (activity_id == search_id) { 686 | idFound = true; 687 | self.createActivityDiv(activity); 688 | } else { 689 | if (init) { 690 | count += 1; 691 | if ((count > 20) && (search_id === null || idFound)) { 692 | return; 693 | } 694 | } 695 | self.createActivityDiv(activity); 696 | } 697 | }); 698 | 699 | // schedule loading the remaining activities 700 | if (this.added_activities < this.activities.length) { 701 | setTimeout(function() { 702 | self.populateActivities(null); 703 | }, 1000); 704 | } 705 | }, 706 | 707 | createActivityDiv: function(activity) { 708 | this.added_activities += 1; 709 | const strava_id = activity['strava_id']; 710 | var activity_div = $('
') 711 | .attr('data-id', strava_id); 712 | var title = `${activity['name']}`; 713 | var table_items = []; 714 | table_items.push({icon: "far fa-calendar-alt", value: activity['start_date_local'].replace('T', ' ')}); 715 | table_items.push({icon: "far fa-question-circle", value: `${activity['type']}`}); 716 | table_items.push({icon: "fas fa-arrows-alt-h", value: this.format_distance(activity['distance'])}); 717 | table_items.push({icon: "fas fa-arrows-alt-v", value: this.format_elevation(activity['total_elevation_gain'])}); 718 | table_items.push({icon: "fas fa-stopwatch", value: activity['moving_time']}); 719 | if (activity['average_speed'] !== null) { 720 | table_items.push({icon: "fas fa-tachometer-alt", value: this.format_pace(activity['average_speed'])}); 721 | } 722 | if (activity['average_heartrate'] !== null) { 723 | table_items.push({icon: "fas fa-heartbeat", value: this.format_heartrate(activity['average_heartrate'])}); 724 | } 725 | if ('streak' in activity) { 726 | table_items.push({icon: "fas fa-hashtag", value: `${activity['streak']}`}); 727 | } 728 | if ('pois' in activity) { 729 | var links = []; 730 | activity['pois'].forEach(category => { 731 | links.push(`${category}`); 732 | }); 733 | if (links.length > 0) { 734 | table_items.push({icon: "fas fa-map-marker-alt", value: links.join(' · ')}); 735 | } 736 | } 737 | var strava = `View on Strava`; 738 | var content = $(`${title}
${this.createTable(table_items)}
${strava}`); 739 | activity_div.append(content); 740 | $('#activities').append(activity_div); 741 | }, 742 | 743 | createTable: function(table_items) { 744 | var contents = []; 745 | table_items.forEach(item => { 746 | const icon = item['icon']; 747 | const value = item['value']; 748 | contents.push(`${value}`); 749 | }); 750 | return contents.join('
'); 751 | }, 752 | 753 | toggleSidebar: function(id) { 754 | if (!id || document.getElementById(id).classList.contains("active")) { 755 | document.querySelector("#sidebar").classList.remove("sidebar-open"); 756 | document.querySelector("#sidebar-controls").classList.remove("sidebar-open"); 757 | document.querySelector("#bottombar").classList.remove("sidebar-open"); 758 | document.querySelector("#map").classList.remove("sidebar-open"); 759 | document.querySelectorAll(".sidebar-control").forEach(control => { 760 | const container_id = control.dataset.container; 761 | const container = document.getElementById(container_id); 762 | control.classList.remove("active"); 763 | container.classList.remove("active"); 764 | }); 765 | } else { 766 | document.querySelector("#sidebar").classList.add("sidebar-open"); 767 | document.querySelector("#sidebar-controls").classList.add("sidebar-open"); 768 | document.querySelector("#bottombar").classList.add("sidebar-open"); 769 | document.querySelector("#map").classList.add("sidebar-open"); 770 | document.querySelectorAll(".sidebar-control").forEach(control => { 771 | const container_id = control.dataset.container; 772 | const container = document.getElementById(container_id); 773 | if (container_id === id) { 774 | control.classList.add("active"); 775 | container.classList.add("active"); 776 | } else { 777 | control.classList.remove("active"); 778 | container.classList.remove("active"); 779 | } 780 | }); 781 | 782 | if (id == "sidebar-activities") { 783 | const activity_div = document.querySelector(`.activity[data-id="${this.selected_activity}"]`); 784 | if (activity_div) { 785 | activity_div.classList.add('is-info'); 786 | activity_div.scrollIntoView({ 787 | behavior: "smooth", 788 | block: "nearest", 789 | inline: "nearest" 790 | }); 791 | } 792 | } 793 | } 794 | 795 | this.map.invalidateSize(false); 796 | }, 797 | 798 | onHeatmapToggle: function (on) { 799 | const self = this; 800 | 801 | if (this.heatmapActive === on) { 802 | return; 803 | } 804 | this.heatmapActive = on; 805 | 806 | if (on) { 807 | if (this.heatmapDataInitialized) { 808 | self.fillHeatmap(); 809 | } else { 810 | $("#heatmap-modal").addClass("is-active"); 811 | setTimeout(() => { 812 | self.fillHeatmap(); 813 | self.heatmapDataInitialized = true; 814 | $("#heatmap-modal").removeClass("is-active"); 815 | }, 0); 816 | } 817 | } 818 | }, 819 | 820 | getHeatmapPointsForActivity: function (activity) { 821 | const self = this; 822 | 823 | if ('heatmap_points' in activity) { 824 | return activity['heatmap_points']; 825 | } 826 | 827 | const points = new Map(); 828 | if (!('summary_polyline' in activity)) { 829 | activity['heatmap_points'] = points; 830 | return points; 831 | } 832 | 833 | const polyline = activity['summary_polyline']; 834 | if (polyline === null || polyline === "") { 835 | activity['heatmap_points'] = points; 836 | return points; 837 | } 838 | 839 | const latlngs = L.PolylineUtil.decode(polyline).map(a => { 840 | return L.latLng(a[0], a[1]); 841 | }); 842 | const accumulated = L.GeometryUtil.accumulatedLengths(latlngs); 843 | const length = accumulated.length > 0 ? accumulated[accumulated.length - 1] : 0; 844 | const offset = 25; 845 | const count = Math.floor(length / offset); 846 | 847 | var j = 0; 848 | for (var i = 1; i <= count; ++i) { 849 | const distance = offset * i; 850 | while ((j < (accumulated.length - 1)) && (accumulated[j] < distance)) { 851 | ++j; 852 | } 853 | const p1 = latlngs[j - 1]; 854 | const p2 = latlngs[j]; 855 | const ratio = (distance - accumulated[j - 1]) / (accumulated[j] - accumulated[j - 1]); 856 | const position = L.GeometryUtil.interpolateOnLine(self.map, [p1, p2], ratio); 857 | const key = `${position.latLng.lat.toFixed(4)}/${position.latLng.lng.toFixed(4)}`; 858 | if (points.has(key)) { 859 | points.get(key).count += 1; 860 | } else { 861 | points.set(key, { 862 | lat: Number(Math.round(position.latLng.lat + 'e4') + 'e-4'), 863 | lng: Number(Math.round(position.latLng.lng + 'e4') + 'e-4'), 864 | count: 1 865 | }); 866 | } 867 | } 868 | 869 | activity['heatmap_points'] = points; 870 | return points; 871 | }, 872 | 873 | fillHeatmap: function() { 874 | if (!this.heatmapActive) { 875 | return; 876 | } 877 | 878 | const self = this; 879 | const points = new Map(); 880 | this.activities.forEach(activity => { 881 | const match = self.matchesFilter(activity); 882 | if (!match) { 883 | return; 884 | } 885 | 886 | self.getHeatmapPointsForActivity(activity).forEach((value, key) => { 887 | if (points.has(key)) { 888 | points.get(key).count += value.count; 889 | } else { 890 | points.set(key, { 891 | lat: value.lat, 892 | lng: value.lng, 893 | count: value.count 894 | }); 895 | } 896 | }); 897 | }); 898 | 899 | var max = 0; 900 | points.forEach(d => { 901 | if (d['count'] > max) { 902 | max = d['count']; 903 | } 904 | }); 905 | 906 | const data = []; 907 | points.forEach(d => { 908 | data.push({lat: d.lat, lng: d.lng, count: Math.log(1 + d.count)}); 909 | }); 910 | 911 | this.heatmap.setData({max: Math.log(1 + max), data: data}); 912 | } 913 | }; 914 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Activities 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 |
20 |
21 | NAME 22 |
23 |
24 | DATE | 25 | DISTANCE | 26 | TIME 27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 225 | 226 | 248 | 249 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | -------------------------------------------------------------------------------- /web/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | overflow-y: auto; 3 | } 4 | 5 | .modal { 6 | z-index: 3000; 7 | } 8 | 9 | #map { 10 | position: absolute; 11 | left: 0; 12 | right: 0; 13 | top: 0; 14 | bottom: 56px; 15 | } 16 | 17 | #map.sidebar-open { 18 | left: 300px; 19 | bottom: 0; 20 | } 21 | 22 | #bottombar { 23 | display: block; 24 | position: fixed; 25 | bottom: 0; 26 | left: 0; 27 | right: 0; 28 | height: 56px; 29 | padding: 0.2rem; 30 | overflow: hidden; 31 | } 32 | 33 | #bottombar.sidebar-open { 34 | display: none; 35 | } 36 | 37 | #bottombar .buttons { 38 | position: absolute; 39 | right: 0.2rem; 40 | top: 0.2rem; 41 | } 42 | 43 | #sidebar { 44 | display: none; 45 | position: fixed; 46 | top: 0; 47 | bottom: 0; 48 | left: 0; 49 | width: 300px; 50 | z-index: 30; 51 | background: white; 52 | } 53 | 54 | #sidebar.sidebar-open { 55 | display: flex; 56 | flex-direction: column; 57 | } 58 | 59 | #sidebar-controls { 60 | display: block; 61 | position: fixed; 62 | left: 0; 63 | top: 0; 64 | bottom: 0; 65 | z-index: 2000; 66 | overflow: hidden; 67 | } 68 | #sidebar-controls.sidebar-open { 69 | left: 300px; 70 | } 71 | 72 | .sidebar-control { 73 | margin-right: 0px; 74 | margin-top: 10px; 75 | 76 | background-color: rgba(0,0,0,0.6); 77 | text-align: center; 78 | } 79 | 80 | .sidebar-control.active { 81 | background-color: hsl(204, 86%, 53%); 82 | } 83 | 84 | .sidebar-control a { 85 | display: block; 86 | width: 40px; 87 | height: 40px; 88 | line-height: 40px; 89 | font-size: 20px; 90 | color: #ffffff; 91 | } 92 | 93 | .sidebar-container { 94 | display: none; 95 | width: 100%; 96 | } 97 | 98 | .sidebar-container.active { 99 | display: flex; 100 | flex-direction: column; 101 | flex-grow: 1; 102 | /* for Firefox */ 103 | min-height: 0; 104 | } 105 | 106 | .sidebar-container > .header { 107 | padding: 10px 20px; 108 | background-color: hsl(204, 86%, 53%); 109 | color: #ffffff; 110 | } 111 | .sidebar-container > .header > .close { 112 | float: right; 113 | cursor: pointer; 114 | } 115 | .sidebar-container > .header > h2 { 116 | margin: 0; 117 | padding: 0; 118 | border: 0; 119 | font-size: 100%; 120 | font-weight: bold; 121 | text-transform: uppercase; 122 | } 123 | 124 | .sidebar-container .content { 125 | margin: 8px; 126 | } 127 | 128 | .sidebar-container .scrollable-content { 129 | flex-grow: 1; 130 | overflow: auto; 131 | 132 | /* for Firefox */ 133 | min-height: 0; 134 | } 135 | 136 | #statistics-table h3 { 137 | margin: 0; 138 | padding: 0; 139 | border: 0; 140 | font-size: 100%; 141 | font-weight: bold; 142 | text-transform: uppercase; 143 | } 144 | 145 | #activities { 146 | padding-top: 1.5rem; 147 | padding-bottom: 1.5rem; 148 | } 149 | 150 | #activities .notification { 151 | margin-left: 1rem; 152 | margin-right: 1rem; 153 | } 154 | 155 | .activity { 156 | cursor: pointer; 157 | } 158 | 159 | .activity .value{ 160 | padding-left: 1rem; 161 | } 162 | 163 | .noUi-connect { 164 | background-color: hsl(204, 86%, 53%); 165 | } 166 | --------------------------------------------------------------------------------