geo-activity-playground 0.39.0__tar.gz → 0.40.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/PKG-INFO +1 -1
  2. geo_activity_playground-0.40.0/geo_activity_playground/__init__.py +0 -0
  3. geo_activity_playground-0.40.0/geo_activity_playground/alembic/versions/93cc82ad1b60_add_parametricplotspec.py +39 -0
  4. geo_activity_playground-0.40.0/geo_activity_playground/core/__init__.py +0 -0
  5. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/activities.py +3 -0
  6. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/datamodel.py +55 -8
  7. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/enrichment.py +7 -5
  8. geo_activity_playground-0.40.0/geo_activity_playground/core/parametric_plot.py +108 -0
  9. geo_activity_playground-0.40.0/geo_activity_playground/core/test_datamodel.py +7 -0
  10. geo_activity_playground-0.40.0/geo_activity_playground/explorer/__init__.py +0 -0
  11. geo_activity_playground-0.40.0/geo_activity_playground/importers/__init__.py +0 -0
  12. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/importers/activity_parsers.py +11 -11
  13. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/importers/strava_checkout.py +7 -0
  14. geo_activity_playground-0.40.0/geo_activity_playground/webui/__init__.py +0 -0
  15. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/app.py +11 -3
  16. geo_activity_playground-0.40.0/geo_activity_playground/webui/blueprints/__init__.py +0 -0
  17. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/activity_blueprint.py +7 -7
  18. geo_activity_playground-0.40.0/geo_activity_playground/webui/blueprints/plot_builder_blueprint.py +89 -0
  19. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/summary_blueprint.py +8 -0
  20. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/activity/show.html.j2 +3 -3
  21. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/page.html.j2 +5 -1
  22. geo_activity_playground-0.40.0/geo_activity_playground/webui/templates/plot_builder/edit.html.j2 +63 -0
  23. geo_activity_playground-0.40.0/geo_activity_playground/webui/templates/plot_builder/index.html.j2 +32 -0
  24. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/summary/index.html.j2 +12 -0
  25. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/pyproject.toml +1 -1
  26. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/LICENSE +0 -0
  27. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/__main__.py +0 -0
  28. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/alembic/README +0 -0
  29. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/alembic/env.py +0 -0
  30. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/alembic/script.py.mako +0 -0
  31. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/alembic/versions/451e7836b53d_add_square_planner_bookmark.py +0 -0
  32. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/alembic/versions/63d3b7f6f93c_initial_version.py +0 -0
  33. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/alembic/versions/ab83b9d23127_add_upstream_id.py +0 -0
  34. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/alembic/versions/b03491c593f6_add_crop_indices.py +0 -0
  35. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/alembic/versions/e02e27876deb_add_square_planner_bookmark_name.py +0 -0
  36. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/alembic/versions/script.py.mako +0 -0
  37. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/config.py +0 -0
  38. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/coordinates.py +0 -0
  39. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/heart_rate.py +0 -0
  40. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/meta_search.py +0 -0
  41. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/paths.py +0 -0
  42. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/privacy_zones.py +0 -0
  43. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/raster_map.py +0 -0
  44. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/similarity.py +0 -0
  45. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/summary_stats.py +0 -0
  46. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/tasks.py +0 -0
  47. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/test_meta_search.py +0 -0
  48. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/test_summary_stats.py +0 -0
  49. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/test_tiles.py +0 -0
  50. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/test_time_conversion.py +0 -0
  51. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/tiles.py +0 -0
  52. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/core/time_conversion.py +0 -0
  53. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/explorer/grid_file.py +0 -0
  54. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/explorer/tile_visits.py +0 -0
  55. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/explorer/video.py +0 -0
  56. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/heatmap_video.py +0 -0
  57. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/importers/csv_parser.py +0 -0
  58. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/importers/directory.py +0 -0
  59. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/importers/strava_api.py +0 -0
  60. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/importers/test_csv_parser.py +0 -0
  61. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/importers/test_directory.py +0 -0
  62. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/importers/test_strava_api.py +0 -0
  63. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/authenticator.py +0 -0
  64. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/auth_blueprint.py +0 -0
  65. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/bubble_chart_blueprint.py +0 -0
  66. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/calendar_blueprint.py +0 -0
  67. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/eddington_blueprint.py +0 -0
  68. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/entry_views.py +0 -0
  69. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/equipment_blueprint.py +0 -0
  70. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/explorer_blueprint.py +0 -0
  71. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/heatmap_blueprint.py +0 -0
  72. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/search_blueprint.py +0 -0
  73. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/settings_blueprint.py +0 -0
  74. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/square_planner_blueprint.py +0 -0
  75. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/tile_blueprint.py +0 -0
  76. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/blueprints/upload_blueprint.py +0 -0
  77. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/flasher.py +0 -0
  78. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/plot_util.py +0 -0
  79. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/search_util.py +0 -0
  80. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/Leaflet.fullscreen.min.js +0 -0
  81. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/MarkerCluster.Default.css +0 -0
  82. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/MarkerCluster.css +0 -0
  83. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
  84. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/android-chrome-512x512.png +0 -0
  85. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
  86. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/bootstrap-dark-mode.js +0 -0
  87. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/bootstrap.bundle.min.js +0 -0
  88. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/bootstrap.min.css +0 -0
  89. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
  90. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
  91. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
  92. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/favicon-48x48.png +0 -0
  93. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/favicon.ico +0 -0
  94. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/favicon.svg +0 -0
  95. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/fullscreen.png +0 -0
  96. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/fullscreen@2x.png +0 -0
  97. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/images/layers-2x.png +0 -0
  98. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/images/layers.png +0 -0
  99. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/images/marker-icon-2x.png +0 -0
  100. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/images/marker-icon.png +0 -0
  101. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/images/marker-shadow.png +0 -0
  102. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/leaflet.css +0 -0
  103. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/leaflet.fullscreen.css +0 -0
  104. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/leaflet.js +0 -0
  105. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/leaflet.markercluster.js +0 -0
  106. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
  107. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/site.webmanifest +0 -0
  108. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/table-sort.min.js +0 -0
  109. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/vega-embed@6 +0 -0
  110. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/vega-lite@4 +0 -0
  111. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/vega@5 +0 -0
  112. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/web-app-manifest-192x192.png +0 -0
  113. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/static/web-app-manifest-512x512.png +0 -0
  114. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/activity/day.html.j2 +0 -0
  115. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/activity/edit.html.j2 +0 -0
  116. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/activity/lines.html.j2 +0 -0
  117. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/activity/name.html.j2 +0 -0
  118. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/activity/trim.html.j2 +0 -0
  119. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/auth/index.html.j2 +0 -0
  120. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/bubble_chart/index.html.j2 +0 -0
  121. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/calendar/index.html.j2 +0 -0
  122. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/calendar/month.html.j2 +0 -0
  123. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/eddington/index.html.j2 +0 -0
  124. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/equipment/index.html.j2 +0 -0
  125. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/explorer/index.html.j2 +0 -0
  126. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/heatmap/index.html.j2 +0 -0
  127. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/home.html.j2 +0 -0
  128. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/search/index.html.j2 +0 -0
  129. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/search_form.html.j2 +0 -0
  130. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/admin-password.html.j2 +0 -0
  131. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/color-schemes.html.j2 +0 -0
  132. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/heart-rate.html.j2 +0 -0
  133. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/index.html.j2 +0 -0
  134. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/manage-equipments.html.j2 +0 -0
  135. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/manage-kinds.html.j2 +0 -0
  136. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/metadata-extraction.html.j2 +0 -0
  137. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/privacy-zones.html.j2 +0 -0
  138. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/segmentation.html.j2 +0 -0
  139. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/sharepic.html.j2 +0 -0
  140. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/settings/strava.html.j2 +0 -0
  141. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/square_planner/index.html.j2 +0 -0
  142. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/upload/index.html.j2 +0 -0
  143. {geo_activity_playground-0.39.0 → geo_activity_playground-0.40.0}/geo_activity_playground/webui/templates/upload/reload.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geo-activity-playground
3
- Version: 0.39.0
3
+ Version: 0.40.0
4
4
  Summary: Analysis of geo data activities like rides, runs or hikes.
5
5
  License: MIT
6
6
  Author: Martin Ueding
@@ -0,0 +1,39 @@
1
+ from typing import Sequence
2
+ from typing import Union
3
+
4
+ import sqlalchemy as sa
5
+ from alembic import op
6
+
7
+
8
+ # revision identifiers, used by Alembic.
9
+ revision: str = "93cc82ad1b60"
10
+ down_revision: Union[str, None] = "e02e27876deb"
11
+ branch_labels: Union[str, Sequence[str], None] = None
12
+ depends_on: Union[str, Sequence[str], None] = None
13
+
14
+
15
+ def upgrade() -> None:
16
+ # ### commands auto generated by Alembic - please adjust! ###
17
+ op.create_table(
18
+ "plot_specs",
19
+ sa.Column("id", sa.Integer(), nullable=False),
20
+ sa.Column("name", sa.String(), nullable=False),
21
+ sa.Column("mark", sa.String(), nullable=True),
22
+ sa.Column("x", sa.String(), nullable=True),
23
+ sa.Column("y", sa.String(), nullable=True),
24
+ sa.Column("color", sa.String(), nullable=True),
25
+ sa.Column("shape", sa.String(), nullable=True),
26
+ sa.Column("size", sa.String(), nullable=True),
27
+ sa.Column("row", sa.String(), nullable=True),
28
+ sa.Column("opacity", sa.String(), nullable=True),
29
+ sa.Column("column", sa.String(), nullable=True),
30
+ sa.Column("facet", sa.String(), nullable=True),
31
+ sa.PrimaryKeyConstraint("id"),
32
+ )
33
+ # ### end Alembic commands ###
34
+
35
+
36
+ def downgrade() -> None:
37
+ # ### commands auto generated by Alembic - please adjust! ###
38
+ op.drop_table("plot_specs")
39
+ # ### end Alembic commands ###
@@ -91,6 +91,9 @@ class ActivityRepository:
91
91
  df["hours"] = [
92
92
  elapsed_time.total_seconds() / 3600 for elapsed_time in df["elapsed_time"]
93
93
  ]
94
+ df["hours_moving"] = [
95
+ moving_time.total_seconds() / 3600 for moving_time in df["moving_time"]
96
+ ]
94
97
  df.index = df["id"]
95
98
  return df
96
99
 
@@ -1,6 +1,8 @@
1
1
  import datetime
2
+ import json
2
3
  import logging
3
4
  from typing import Any
5
+ from typing import Optional
4
6
  from typing import TypedDict
5
7
 
6
8
  import numpy as np
@@ -109,21 +111,25 @@ class Activity(DB.Model):
109
111
  return f"{self.start} {self.name}"
110
112
 
111
113
  @property
112
- def average_speed_moving_kmh(self) -> float:
113
- return self.distance_km / (self.moving_time.total_seconds() / 3_600)
114
+ def average_speed_moving_kmh(self) -> Optional[float]:
115
+ if self.moving_time is not None:
116
+ return self.distance_km / (self.moving_time.total_seconds() / 3_600)
114
117
 
115
118
  @property
116
- def average_speed_elapsed_kmh(self) -> float:
117
- return self.distance_km / (self.elapsed_time.total_seconds() / 3_600)
119
+ def average_speed_elapsed_kmh(self) -> Optional[float]:
120
+ if self.elapsed_time is not None:
121
+ return self.distance_km / (self.elapsed_time.total_seconds() / 3_600)
118
122
 
119
123
  @property
120
124
  def raw_time_series(self) -> pd.DataFrame:
121
125
  path = time_series_dir() / f"{self.id}.parquet"
122
126
  try:
123
- return pd.read_parquet(path)
127
+ time_series = pd.read_parquet(path)
128
+ if "altitude" in time_series.columns:
129
+ time_series.rename(columns={"altitude": "elevation"}, inplace=True)
130
+ return time_series
124
131
  except OSError as e:
125
- logger.error(f"Error while reading {path}, deleting cache file …")
126
- path.unlink(missing_ok=True)
132
+ logger.error(f"Error while reading {path}.")
127
133
  raise
128
134
 
129
135
  @property
@@ -234,7 +240,7 @@ def get_or_make_kind(name: str, config: Config) -> Kind:
234
240
  else:
235
241
  kind = Kind(
236
242
  name=name,
237
- consider_for_achievements=config.kinds_without_achievements.get(name, True),
243
+ consider_for_achievements=name in config.kinds_without_achievements,
238
244
  )
239
245
  DB.session.add(kind)
240
246
  return kind
@@ -255,3 +261,44 @@ def get_or_make_equipment(name: str, config: Config) -> Equipment:
255
261
  )
256
262
  DB.session.add(equipment)
257
263
  return equipment
264
+
265
+
266
+ class PlotSpec(DB.Model):
267
+ __tablename__ = "plot_specs"
268
+
269
+ id: Mapped[int] = mapped_column(primary_key=True)
270
+
271
+ name: Mapped[str] = mapped_column(sa.String, nullable=False)
272
+
273
+ mark: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
274
+ x: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
275
+ y: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
276
+ color: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
277
+ shape: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
278
+ size: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
279
+ row: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
280
+ opacity: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
281
+ column: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
282
+ facet: Mapped[str] = mapped_column(sa.String, nullable=False, default="")
283
+
284
+ FIELDS = [
285
+ "name",
286
+ "mark",
287
+ "x",
288
+ "y",
289
+ "color",
290
+ "shape",
291
+ "size",
292
+ "row",
293
+ "opacity",
294
+ "column",
295
+ "facet",
296
+ ]
297
+
298
+ def __str__(self) -> str:
299
+ return self.name
300
+
301
+ def to_json(self) -> str:
302
+ return json.dumps(
303
+ {key: getattr(self, key) for key in self.FIELDS if getattr(self, key)}
304
+ )
@@ -192,10 +192,12 @@ def _embellish_single_time_series(
192
192
  timeseries["y"] = y
193
193
 
194
194
  if "altitude" in timeseries.columns:
195
- altitude_diff = timeseries["altitude"].diff()
196
- altitude_diff = altitude_diff.ewm(span=5, min_periods=5).mean()
197
- altitude_diff.loc[altitude_diff.abs() > 30] = 0
198
- altitude_diff.loc[altitude_diff < 0] = 0
199
- timeseries["elevation_gain_cum"] = altitude_diff.cumsum()
195
+ timeseries.rename(columns={"altitude": "elevation"}, inplace=True)
196
+ if "elevation" in timeseries.columns:
197
+ elevation_diff = timeseries["elevation"].diff()
198
+ elevation_diff = elevation_diff.ewm(span=5, min_periods=5).mean()
199
+ elevation_diff.loc[elevation_diff.abs() > 30] = 0
200
+ elevation_diff.loc[elevation_diff < 0] = 0
201
+ timeseries["elevation_gain_cum"] = elevation_diff.cumsum()
200
202
 
201
203
  return timeseries
@@ -0,0 +1,108 @@
1
+ import altair as alt
2
+ import pandas as pd
3
+
4
+ from .datamodel import PlotSpec
5
+
6
+
7
+ MARKS = {
8
+ "point": "Point",
9
+ "circle": "Circle",
10
+ "area": "Area",
11
+ "bar": "Bar",
12
+ "rect": "Rectangle",
13
+ }
14
+ CONTINUOUS_VARIABLES = {
15
+ "distance_km": "Distance / km",
16
+ "sum(distance_km)": "Total distance / km",
17
+ "mean(distance_km)": "Average distance / km",
18
+ "start": "Date",
19
+ "hours": "Elapsed time / h",
20
+ "hours_moving": "Moving time / h",
21
+ "calories": "Energy / kcal",
22
+ "steps": "Steps",
23
+ "elevation_gain": "Elevation gain / m",
24
+ "start_elevation": "Start elevation / m",
25
+ "end_elevation": "End elevation / m",
26
+ "sum(elevation_gain)": "Total elevation gain / m",
27
+ "mean(elevation_gain)": "Average elevation gain / m",
28
+ "num_new_tiles_14": "New tiles 14",
29
+ "num_new_tiles_14": "New tiles 17",
30
+ "average_speed_moving_kmh": "Average moving speed / km/h",
31
+ "average_speed_elapsed_kmh": "Average elapsed speed / km/h",
32
+ "start_latitude": "Start latitude / °",
33
+ "start_longitude": "Start longitude / °",
34
+ "end_latitude": "End latitude / °",
35
+ "end_longitude": "End longitude / °",
36
+ }
37
+ DISCRETE_VARIABLES = {
38
+ "equipment": "Equipment",
39
+ "kind": "Activity kind",
40
+ "consider_for_achievements": "Consider for achievements",
41
+ "year(start):O": "Year",
42
+ "iso_year:O": "ISO Year",
43
+ "yearquarter(start)": "Year, Quarter",
44
+ "yearquartermonth(start)": "Year, Quarter, Month",
45
+ "yearmonth(start)": "Year, Month",
46
+ "quarter(start)": "Quarter",
47
+ "quartermonth(start)": "Quarter, Month",
48
+ "month(start)": "Month",
49
+ "week:O": "ISO Week",
50
+ "date(start)": "Day of month",
51
+ "weekday(start)": "Day of week",
52
+ }
53
+
54
+ VARIABLES_1 = {"": "", **DISCRETE_VARIABLES}
55
+ VARIABLES_2 = {"": "", **DISCRETE_VARIABLES, **CONTINUOUS_VARIABLES}
56
+
57
+
58
+ def make_parametric_plot(df: pd.DataFrame, spec: PlotSpec) -> str:
59
+ chart = alt.Chart(df)
60
+
61
+ match spec.mark:
62
+ case "point":
63
+ chart = chart.mark_point()
64
+ case "circle":
65
+ chart = chart.mark_circle()
66
+ case "area":
67
+ chart = chart.mark_area()
68
+ case "bar":
69
+ chart = chart.mark_bar()
70
+ case "rect":
71
+ chart = chart.mark_rect()
72
+ case _:
73
+ raise ValueError()
74
+
75
+ encodings = [
76
+ alt.X(spec.x, title=VARIABLES_2[spec.x]),
77
+ alt.Y(spec.y, title=VARIABLES_2[spec.y]),
78
+ ]
79
+ tooltips = [
80
+ alt.Tooltip(spec.x, title=VARIABLES_2[spec.x]),
81
+ alt.Tooltip(spec.y, title=VARIABLES_2[spec.y]),
82
+ ]
83
+
84
+ if spec.color:
85
+ encodings.append(alt.Color(spec.color, title=VARIABLES_2[spec.color]))
86
+ tooltips.append(alt.Tooltip(spec.color, title=VARIABLES_2[spec.color]))
87
+ if spec.shape:
88
+ encodings.append(alt.Shape(spec.shape, title=VARIABLES_2[spec.shape]))
89
+ tooltips.append(alt.Tooltip(spec.shape, title=VARIABLES_2[spec.shape]))
90
+ if spec.size:
91
+ encodings.append(alt.Size(spec.size, title=VARIABLES_2[spec.size]))
92
+ tooltips.append(alt.Tooltip(spec.size, title=VARIABLES_2[spec.size]))
93
+ if spec.opacity:
94
+ encodings.append(alt.Size(spec.opacity, title=VARIABLES_2[spec.opacity]))
95
+ tooltips.append(alt.Opacity(spec.opacity, title=VARIABLES_2[spec.opacity]))
96
+ if spec.row:
97
+ encodings.append(alt.Row(spec.row, title=VARIABLES_2[spec.row]))
98
+ tooltips.append(alt.Tooltip(spec.row, title=VARIABLES_2[spec.row]))
99
+ if spec.column:
100
+ encodings.append(alt.Column(spec.column, title=VARIABLES_2[spec.column]))
101
+ tooltips.append(alt.Tooltip(spec.column, title=VARIABLES_2[spec.column]))
102
+ if spec.facet:
103
+ encodings.append(
104
+ alt.Facet(spec.facet, columns=3, title=VARIABLES_2[spec.facet])
105
+ )
106
+ tooltips.append(alt.Tooltip(spec.facet, title=VARIABLES_2[spec.facet]))
107
+
108
+ return chart.encode(*encodings, tooltips).interactive().to_json(format="vega")
@@ -0,0 +1,7 @@
1
+ from .datamodel import Activity
2
+
3
+
4
+ def test_zero_duration() -> None:
5
+ activity = Activity(name="Test", distance_km=10.0)
6
+ assert activity.average_speed_elapsed_kmh is None
7
+ assert activity.average_speed_moving_kmh is None
@@ -121,9 +121,9 @@ def read_fit_activity(path: pathlib.Path, open) -> tuple[ActivityMeta, pd.DataFr
121
121
  if "distance" in fields:
122
122
  row["distance"] = values["distance"]
123
123
  if "altitude" in fields:
124
- row["altitude"] = values["altitude"]
124
+ row["elevation"] = values["altitude"]
125
125
  if "enhanced_altitude" in fields:
126
- row["altitude"] = values["enhanced_altitude"]
126
+ row["elevation"] = values["enhanced_altitude"]
127
127
  if "speed" in fields:
128
128
  factor = _fit_speed_unit_factor(fields["speed"].units)
129
129
  row["speed"] = values["speed"] * factor
@@ -188,10 +188,10 @@ def read_gpx_activity(path: pathlib.Path, open) -> pd.DataFrame:
188
188
  time = convert_to_datetime_ns(time)
189
189
  points.append((time, point.latitude, point.longitude, point.elevation))
190
190
 
191
- df = pd.DataFrame(points, columns=["time", "latitude", "longitude", "altitude"])
192
- # Some files don't have altitude information. In these cases we remove the column.
193
- if not df["altitude"].any():
194
- del df["altitude"]
191
+ df = pd.DataFrame(points, columns=["time", "latitude", "longitude", "elevation"])
192
+ # Some files don't have elevation information. In these cases we remove the column.
193
+ if not df["elevation"].any():
194
+ del df["elevation"]
195
195
  return df
196
196
 
197
197
 
@@ -230,7 +230,7 @@ def read_tcx_activity(path: pathlib.Path, opener) -> pd.DataFrame:
230
230
  "longitude": trackpoint.longitude,
231
231
  }
232
232
  if trackpoint.elevation:
233
- row["altitude"] = trackpoint.elevation
233
+ row["elevation"] = trackpoint.elevation
234
234
  if trackpoint.hr_value:
235
235
  row["heartrate"] = trackpoint.hr_value
236
236
  if trackpoint.cadence:
@@ -256,16 +256,16 @@ def read_kml_activity(path: pathlib.Path, opener) -> pd.DataFrame:
256
256
  parts = where.split(" ")
257
257
  if len(parts) == 2:
258
258
  lon, lat = parts
259
- alt = None
259
+ elevation = None
260
260
  if len(parts) == 3:
261
- lon, lat, alt = parts
261
+ lon, lat, elevation = parts
262
262
  row = {
263
263
  "time": time,
264
264
  "latitude": float(lat),
265
265
  "longitude": float(lon),
266
266
  }
267
- if alt is not None:
268
- row["altitude"] = float(alt)
267
+ if elevation is not None:
268
+ row["elevation"] = float(elevation)
269
269
  rows.append(row)
270
270
  return pd.DataFrame(rows)
271
271
 
@@ -258,6 +258,13 @@ def convert_strava_checkout(
258
258
  activities = pd.read_csv(checkout_path / "activities.csv")
259
259
  print(activities)
260
260
 
261
+ # Handle German localization.
262
+ if activities.columns[0] == "Aktivitäts-ID":
263
+ assert len(activities.columns) == len(
264
+ EXPECTED_COLUMNS
265
+ ), "Strava seems to have changed for format again. Please file a bug report at https://github.com/martin-ueding/geo-activity-playground/issues and include the first line of the 'activities.csv'."
266
+ activities.columns = EXPECTED_COLUMNS
267
+
261
268
  for _, row in tqdm(activities.iterrows(), desc="Import activity files"):
262
269
  # Some people have manually added activities without position data. These don't have a file there. We'll skip these.
263
270
  if not isinstance(row["Filename"], str):
@@ -1,6 +1,7 @@
1
1
  import datetime
2
2
  import importlib
3
3
  import json
4
+ import logging
4
5
  import os
5
6
  import pathlib
6
7
  import secrets
@@ -32,6 +33,7 @@ from .blueprints.entry_views import register_entry_views
32
33
  from .blueprints.equipment_blueprint import make_equipment_blueprint
33
34
  from .blueprints.explorer_blueprint import make_explorer_blueprint
34
35
  from .blueprints.heatmap_blueprint import make_heatmap_blueprint
36
+ from .blueprints.plot_builder_blueprint import make_plot_builder_blueprint
35
37
  from .blueprints.search_blueprint import make_search_blueprint
36
38
  from .blueprints.settings_blueprint import make_settings_blueprint
37
39
  from .blueprints.square_planner_blueprint import make_square_planner_blueprint
@@ -43,6 +45,9 @@ from .flasher import FlaskFlasher
43
45
  from .search_util import SearchQueryHistory
44
46
 
45
47
 
48
+ logger = logging.getLogger(__name__)
49
+
50
+
46
51
  def get_secret_key():
47
52
  secret_file = pathlib.Path("Cache/flask-secret.json")
48
53
  if secret_file.exists():
@@ -65,9 +70,9 @@ def web_ui_main(
65
70
 
66
71
  app = Flask(__name__)
67
72
 
68
- app.config["SQLALCHEMY_DATABASE_URI"] = (
69
- f"sqlite:///{basedir.absolute()}/database.sqlite"
70
- )
73
+ database_path = basedir / "database.sqlite"
74
+ logger.info(f"Using database file at '{database_path.absolute()}'.")
75
+ app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{database_path.absolute()}"
71
76
  app.config["ALEMBIC"] = {"script_location": "../alembic/versions"}
72
77
  DB.init_app(app)
73
78
 
@@ -137,6 +142,9 @@ def web_ui_main(
137
142
  "/heatmap": make_heatmap_blueprint(
138
143
  repository, tile_visit_accessor, config_accessor(), search_query_history
139
144
  ),
145
+ "/plot-builder": make_plot_builder_blueprint(
146
+ repository, flasher, authenticator
147
+ ),
140
148
  "/settings": make_settings_blueprint(config_accessor, authenticator, flasher),
141
149
  "/square-planner": make_square_planner_blueprint(tile_visit_accessor),
142
150
  "/search": make_search_blueprint(
@@ -148,8 +148,8 @@ def make_activity_blueprint(
148
148
  )
149
149
  ) is not None:
150
150
  context["heart_zones_plot"] = heart_rate_zone_plot(heart_zones)
151
- if "altitude" in time_series.columns:
152
- context["altitude_time_plot"] = altitude_time_plot(time_series)
151
+ if "elevation" in time_series.columns:
152
+ context["elevation_time_plot"] = elevation_time_plot(time_series)
153
153
  if "elevation_gain_cum" in time_series.columns:
154
154
  context["elevation_gain_cum_plot"] = elevation_gain_cum_plot(time_series)
155
155
  if "heartrate" in time_series.columns:
@@ -445,13 +445,13 @@ def distance_time_plot(time_series: pd.DataFrame) -> str:
445
445
  )
446
446
 
447
447
 
448
- def altitude_time_plot(time_series: pd.DataFrame) -> str:
448
+ def elevation_time_plot(time_series: pd.DataFrame) -> str:
449
449
  return (
450
- alt.Chart(time_series, title="Altitude")
450
+ alt.Chart(time_series, title="Elevation")
451
451
  .mark_line()
452
452
  .encode(
453
453
  alt.X("time", title="Time"),
454
- alt.Y("altitude", scale=alt.Scale(zero=False), title="Altitude / m"),
454
+ alt.Y("elevation", scale=alt.Scale(zero=False), title="Elevation / m"),
455
455
  alt.Color("segment_id:N", title="Segment"),
456
456
  )
457
457
  .interactive(bind_y=False)
@@ -461,14 +461,14 @@ def altitude_time_plot(time_series: pd.DataFrame) -> str:
461
461
 
462
462
  def elevation_gain_cum_plot(time_series: pd.DataFrame) -> str:
463
463
  return (
464
- alt.Chart(time_series, title="Altitude Gain")
464
+ alt.Chart(time_series, title="Elevation Gain")
465
465
  .mark_line()
466
466
  .encode(
467
467
  alt.X("time", title="Time"),
468
468
  alt.Y(
469
469
  "elevation_gain_cum",
470
470
  scale=alt.Scale(zero=False),
471
- title="Altitude gain / m",
471
+ title="Elevation gain / m",
472
472
  ),
473
473
  alt.Color("segment_id:N", title="Segment"),
474
474
  )
@@ -0,0 +1,89 @@
1
+ import sqlalchemy
2
+ from flask import Blueprint
3
+ from flask import redirect
4
+ from flask import render_template
5
+ from flask import request
6
+ from flask import Response
7
+ from flask import url_for
8
+
9
+ from ...core.activities import ActivityRepository
10
+ from ...core.datamodel import DB
11
+ from ...core.parametric_plot import make_parametric_plot
12
+ from ...core.parametric_plot import MARKS
13
+ from ...core.parametric_plot import PlotSpec
14
+ from ...core.parametric_plot import VARIABLES_1
15
+ from ...core.parametric_plot import VARIABLES_2
16
+ from ..authenticator import Authenticator
17
+ from ..authenticator import needs_authentication
18
+ from ..flasher import Flasher
19
+ from ..flasher import FlashTypes
20
+
21
+
22
+ def make_plot_builder_blueprint(
23
+ repository: ActivityRepository, flasher: Flasher, authenticator: Authenticator
24
+ ) -> Blueprint:
25
+ blueprint = Blueprint("plot_builder", __name__, template_folder="templates")
26
+
27
+ @blueprint.route("/")
28
+ def index() -> Response:
29
+ return render_template(
30
+ "plot_builder/index.html.j2",
31
+ specs=DB.session.scalars(sqlalchemy.select(PlotSpec)).all(),
32
+ )
33
+
34
+ @blueprint.route("/new")
35
+ @needs_authentication(authenticator)
36
+ def new() -> Response:
37
+ spec = PlotSpec(
38
+ name="My New Plot",
39
+ mark="bar",
40
+ x="year(start):O",
41
+ y="sum(distance_km)",
42
+ color="kind",
43
+ )
44
+ DB.session.add(spec)
45
+ DB.session.commit()
46
+ return redirect(url_for(".edit", id=spec.id))
47
+
48
+ @blueprint.route("/edit/<int:id>")
49
+ @needs_authentication(authenticator)
50
+ def edit(id: int) -> Response:
51
+ spec = DB.session.get(PlotSpec, id)
52
+ if request.args:
53
+ spec.name = request.args["name"]
54
+ spec.mark = request.args["mark"]
55
+ spec.x = request.args["x"]
56
+ spec.y = request.args["y"]
57
+ spec.color = request.args["color"]
58
+ spec.shape = request.args["shape"]
59
+ spec.size = request.args["size"]
60
+ spec.size = request.args["size"]
61
+ spec.row = request.args["row"]
62
+ spec.column = request.args["column"]
63
+ spec.facet = request.args["facet"]
64
+ spec.opacity = request.args["opacity"]
65
+ try:
66
+ plot = make_parametric_plot(repository.meta, spec)
67
+ DB.session.commit()
68
+ except ValueError as e:
69
+ plot = None
70
+ flasher.flash_message(str(e), FlashTypes.WARNING)
71
+ return render_template(
72
+ "plot_builder/edit.html.j2",
73
+ marks=MARKS,
74
+ discrete=VARIABLES_1,
75
+ continuous=VARIABLES_2,
76
+ plot=plot,
77
+ spec=spec,
78
+ )
79
+
80
+ @blueprint.route("/delete/<int:id>")
81
+ @needs_authentication(authenticator)
82
+ def delete(id: int) -> Response:
83
+ spec = DB.session.get(PlotSpec, id)
84
+ DB.session.delete(spec)
85
+ flasher.flash_message(f"Deleted plot '{spec.name}'.", FlashTypes.SUCCESS)
86
+ DB.session.commit()
87
+ return redirect(url_for(".index"))
88
+
89
+ return blueprint
@@ -3,6 +3,7 @@ import datetime
3
3
 
4
4
  import altair as alt
5
5
  import pandas as pd
6
+ import sqlalchemy
6
7
  from flask import Blueprint
7
8
  from flask import render_template
8
9
  from flask import request
@@ -10,7 +11,10 @@ from flask import request
10
11
  from ...core.activities import ActivityRepository
11
12
  from ...core.activities import make_geojson_from_time_series
12
13
  from ...core.config import Config
14
+ from ...core.datamodel import DB
15
+ from ...core.datamodel import PlotSpec
13
16
  from ...core.meta_search import apply_search_query
17
+ from ...core.parametric_plot import make_parametric_plot
14
18
  from ..plot_util import make_kind_scale
15
19
  from ..search_util import search_query_from_form
16
20
  from ..search_util import SearchQueryHistory
@@ -62,6 +66,10 @@ def make_summary_blueprint(
62
66
  for activity_id, reasons in nominations.items()
63
67
  ],
64
68
  query=query.to_jinja(),
69
+ custom_plots=[
70
+ (spec, make_parametric_plot(repository.meta, spec))
71
+ for spec in DB.session.scalars(sqlalchemy.select(PlotSpec)).all()
72
+ ],
65
73
  )
66
74
 
67
75
  return blueprint
@@ -132,16 +132,16 @@
132
132
  </div>
133
133
  </div>
134
134
 
135
- {% if altitude_time_plot is defined %}
135
+ {% if elevation_time_plot is defined %}
136
136
  <div class="row mb-3">
137
137
  <div class="col">
138
- <h2>Altitude</h2>
138
+ <h2>Elevation</h2>
139
139
  </div>
140
140
  </div>
141
141
 
142
142
  <div class="row mb-3">
143
143
  <div class="col-md-4">
144
- {{ vega_direct("altitude_time_plot", altitude_time_plot) }}
144
+ {{ vega_direct("elevation_time_plot", elevation_time_plot) }}
145
145
  </div>
146
146
  {% if elevation_gain_cum_plot is defined %}
147
147
  <div class="col-md-4">
@@ -130,6 +130,8 @@
130
130
  <hr class="dropdown-divider">
131
131
  </li>
132
132
 
133
+ <li><a class="dropdown-item" href="{{ url_for('plot_builder.index') }}">Plot Builder</a>
134
+ </li>
133
135
  <li><a class="dropdown-item" href="{{ url_for('settings.index') }}">Settings</a></li>
134
136
  </ul>
135
137
  </li>
@@ -196,7 +198,9 @@
196
198
 
197
199
  <div class="row border-top py-3 my-4">
198
200
  <ul class="nav col-4">
199
- <li class="nav-item px-2 nav-link"><a href="https://github.com/martin-ueding/geo-activity-playground/blob/main/docs/changelog.md" class="nav-link px-2 text-muted" target="_blank">Version {{ version }}</a></li>
201
+ <li class="nav-item px-2 nav-link"><a
202
+ href="https://github.com/martin-ueding/geo-activity-playground/blob/main/docs/changelog.md"
203
+ class="nav-link px-2 text-muted" target="_blank">Version {{ version }}</a></li>
200
204
  </ul>
201
205
  <ul class="nav col-8 justify-content-end">
202
206
  <li class="nav-item"><a href="https://github.com/martin-ueding/geo-activity-playground"