geo-activity-playground 0.38.2__py3-none-any.whl → 0.39.0__py3-none-any.whl

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 (113) hide show
  1. geo_activity_playground/__main__.py +5 -47
  2. geo_activity_playground/alembic/README +1 -0
  3. geo_activity_playground/alembic/env.py +76 -0
  4. geo_activity_playground/alembic/script.py.mako +26 -0
  5. geo_activity_playground/alembic/versions/451e7836b53d_add_square_planner_bookmark.py +33 -0
  6. geo_activity_playground/alembic/versions/63d3b7f6f93c_initial_version.py +73 -0
  7. geo_activity_playground/alembic/versions/ab83b9d23127_add_upstream_id.py +28 -0
  8. geo_activity_playground/alembic/versions/b03491c593f6_add_crop_indices.py +30 -0
  9. geo_activity_playground/alembic/versions/e02e27876deb_add_square_planner_bookmark_name.py +28 -0
  10. geo_activity_playground/alembic/versions/script.py.mako +28 -0
  11. geo_activity_playground/core/activities.py +50 -136
  12. geo_activity_playground/core/config.py +3 -3
  13. geo_activity_playground/core/datamodel.py +257 -0
  14. geo_activity_playground/core/enrichment.py +90 -92
  15. geo_activity_playground/core/heart_rate.py +1 -2
  16. geo_activity_playground/core/paths.py +6 -7
  17. geo_activity_playground/core/raster_map.py +43 -4
  18. geo_activity_playground/core/similarity.py +1 -2
  19. geo_activity_playground/core/tasks.py +2 -2
  20. geo_activity_playground/core/test_meta_search.py +3 -3
  21. geo_activity_playground/core/test_summary_stats.py +1 -1
  22. geo_activity_playground/explorer/grid_file.py +2 -2
  23. geo_activity_playground/explorer/tile_visits.py +8 -10
  24. geo_activity_playground/heatmap_video.py +7 -8
  25. geo_activity_playground/importers/activity_parsers.py +2 -2
  26. geo_activity_playground/importers/directory.py +9 -10
  27. geo_activity_playground/importers/strava_api.py +9 -9
  28. geo_activity_playground/importers/strava_checkout.py +12 -13
  29. geo_activity_playground/importers/test_csv_parser.py +3 -3
  30. geo_activity_playground/importers/test_directory.py +1 -1
  31. geo_activity_playground/importers/test_strava_api.py +1 -1
  32. geo_activity_playground/webui/app.py +94 -86
  33. geo_activity_playground/webui/authenticator.py +1 -1
  34. geo_activity_playground/webui/{activity/controller.py → blueprints/activity_blueprint.py} +246 -108
  35. geo_activity_playground/webui/{auth_blueprint.py → blueprints/auth_blueprint.py} +1 -1
  36. geo_activity_playground/webui/blueprints/bubble_chart_blueprint.py +61 -0
  37. geo_activity_playground/webui/{calendar/controller.py → blueprints/calendar_blueprint.py} +19 -19
  38. geo_activity_playground/webui/{eddington_blueprint.py → blueprints/eddington_blueprint.py} +5 -5
  39. geo_activity_playground/webui/blueprints/entry_views.py +68 -0
  40. geo_activity_playground/webui/{equipment_blueprint.py → blueprints/equipment_blueprint.py} +37 -4
  41. geo_activity_playground/webui/{explorer/controller.py → blueprints/explorer_blueprint.py} +88 -54
  42. geo_activity_playground/webui/blueprints/heatmap_blueprint.py +233 -0
  43. geo_activity_playground/webui/{search_blueprint.py → blueprints/search_blueprint.py} +7 -11
  44. geo_activity_playground/webui/blueprints/settings_blueprint.py +446 -0
  45. geo_activity_playground/webui/{square_planner_blueprint.py → blueprints/square_planner_blueprint.py} +31 -6
  46. geo_activity_playground/webui/{summary_blueprint.py → blueprints/summary_blueprint.py} +11 -23
  47. geo_activity_playground/webui/blueprints/tile_blueprint.py +27 -0
  48. geo_activity_playground/webui/{upload_blueprint.py → blueprints/upload_blueprint.py} +13 -18
  49. geo_activity_playground/webui/flasher.py +26 -0
  50. geo_activity_playground/webui/plot_util.py +1 -1
  51. geo_activity_playground/webui/search_util.py +4 -6
  52. geo_activity_playground/webui/static/images/layers-2x.png +0 -0
  53. geo_activity_playground/webui/static/images/layers.png +0 -0
  54. geo_activity_playground/webui/static/images/marker-icon-2x.png +0 -0
  55. geo_activity_playground/webui/static/images/marker-icon.png +0 -0
  56. geo_activity_playground/webui/static/images/marker-shadow.png +0 -0
  57. geo_activity_playground/webui/templates/activity/day.html.j2 +81 -0
  58. geo_activity_playground/webui/templates/activity/edit.html.j2 +38 -0
  59. geo_activity_playground/webui/{activity/templates → templates}/activity/name.html.j2 +29 -27
  60. geo_activity_playground/webui/{activity/templates → templates}/activity/show.html.j2 +57 -33
  61. geo_activity_playground/webui/templates/activity/trim.html.j2 +68 -0
  62. geo_activity_playground/webui/templates/bubble_chart/index.html.j2 +26 -0
  63. geo_activity_playground/webui/templates/calendar/index.html.j2 +48 -0
  64. geo_activity_playground/webui/templates/calendar/month.html.j2 +57 -0
  65. geo_activity_playground/webui/templates/equipment/index.html.j2 +7 -0
  66. geo_activity_playground/webui/templates/home.html.j2 +6 -6
  67. geo_activity_playground/webui/templates/page.html.j2 +2 -1
  68. geo_activity_playground/webui/{settings/templates → templates}/settings/index.html.j2 +9 -20
  69. geo_activity_playground/webui/templates/settings/manage-equipments.html.j2 +49 -0
  70. geo_activity_playground/webui/templates/settings/manage-kinds.html.j2 +48 -0
  71. geo_activity_playground/webui/{settings/templates → templates}/settings/privacy-zones.html.j2 +2 -0
  72. geo_activity_playground/webui/{settings/templates → templates}/settings/strava.html.j2 +2 -0
  73. geo_activity_playground/webui/templates/square_planner/index.html.j2 +63 -13
  74. {geo_activity_playground-0.38.2.dist-info → geo_activity_playground-0.39.0.dist-info}/METADATA +5 -1
  75. geo_activity_playground-0.39.0.dist-info/RECORD +133 -0
  76. geo_activity_playground/__init__.py +0 -0
  77. geo_activity_playground/core/__init__.py +0 -0
  78. geo_activity_playground/explorer/__init__.py +0 -0
  79. geo_activity_playground/importers/__init__.py +0 -0
  80. geo_activity_playground/webui/__init__.py +0 -0
  81. geo_activity_playground/webui/activity/__init__.py +0 -0
  82. geo_activity_playground/webui/activity/blueprint.py +0 -109
  83. geo_activity_playground/webui/activity/templates/activity/day.html.j2 +0 -80
  84. geo_activity_playground/webui/activity/templates/activity/edit.html.j2 +0 -42
  85. geo_activity_playground/webui/calendar/__init__.py +0 -0
  86. geo_activity_playground/webui/calendar/blueprint.py +0 -23
  87. geo_activity_playground/webui/calendar/templates/calendar/index.html.j2 +0 -46
  88. geo_activity_playground/webui/calendar/templates/calendar/month.html.j2 +0 -55
  89. geo_activity_playground/webui/entry_controller.py +0 -63
  90. geo_activity_playground/webui/explorer/__init__.py +0 -0
  91. geo_activity_playground/webui/explorer/blueprint.py +0 -62
  92. geo_activity_playground/webui/heatmap/__init__.py +0 -0
  93. geo_activity_playground/webui/heatmap/blueprint.py +0 -51
  94. geo_activity_playground/webui/heatmap/heatmap_controller.py +0 -216
  95. geo_activity_playground/webui/settings/blueprint.py +0 -262
  96. geo_activity_playground/webui/settings/controller.py +0 -272
  97. geo_activity_playground/webui/settings/templates/settings/equipment-offsets.html.j2 +0 -44
  98. geo_activity_playground/webui/settings/templates/settings/kind-renames.html.j2 +0 -25
  99. geo_activity_playground/webui/settings/templates/settings/kinds-without-achievements.html.j2 +0 -30
  100. geo_activity_playground/webui/tile_blueprint.py +0 -42
  101. geo_activity_playground-0.38.2.dist-info/RECORD +0 -129
  102. /geo_activity_playground/webui/{activity/templates → templates}/activity/lines.html.j2 +0 -0
  103. /geo_activity_playground/webui/{explorer/templates → templates}/explorer/index.html.j2 +0 -0
  104. /geo_activity_playground/webui/{heatmap/templates → templates}/heatmap/index.html.j2 +0 -0
  105. /geo_activity_playground/webui/{settings/templates → templates}/settings/admin-password.html.j2 +0 -0
  106. /geo_activity_playground/webui/{settings/templates → templates}/settings/color-schemes.html.j2 +0 -0
  107. /geo_activity_playground/webui/{settings/templates → templates}/settings/heart-rate.html.j2 +0 -0
  108. /geo_activity_playground/webui/{settings/templates → templates}/settings/metadata-extraction.html.j2 +0 -0
  109. /geo_activity_playground/webui/{settings/templates → templates}/settings/segmentation.html.j2 +0 -0
  110. /geo_activity_playground/webui/{settings/templates → templates}/settings/sharepic.html.j2 +0 -0
  111. {geo_activity_playground-0.38.2.dist-info → geo_activity_playground-0.39.0.dist-info}/LICENSE +0 -0
  112. {geo_activity_playground-0.38.2.dist-info → geo_activity_playground-0.39.0.dist-info}/WHEEL +0 -0
  113. {geo_activity_playground-0.38.2.dist-info → geo_activity_playground-0.39.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,68 @@
1
+ import collections
2
+ import datetime
3
+
4
+ import altair as alt
5
+ import flask
6
+ import pandas as pd
7
+ from flask import render_template
8
+ from flask import Response
9
+
10
+ from ...core.activities import ActivityRepository
11
+ from ...core.activities import make_geojson_from_time_series
12
+ from ...core.config import Config
13
+ from ..plot_util import make_kind_scale
14
+
15
+
16
+ def register_entry_views(
17
+ app: flask.Flask, repository: ActivityRepository, config: Config
18
+ ) -> None:
19
+ @app.route("/")
20
+ def index() -> Response:
21
+ context = {"latest_activities": []}
22
+
23
+ if len(repository):
24
+ kind_scale = make_kind_scale(repository.meta, config)
25
+ context["distance_last_30_days_plot"] = _distance_last_30_days_meta_plot(
26
+ repository.meta, kind_scale
27
+ )
28
+
29
+ meta = repository.meta.copy()
30
+ meta["date"] = meta["start"].dt.date
31
+
32
+ context["latest_activities"] = collections.defaultdict(list)
33
+ for date, activity_meta in list(meta.groupby("date"))[:-30:-1]:
34
+ for index, activity in activity_meta.iterrows():
35
+ time_series = repository.get_time_series(activity["id"])
36
+ context["latest_activities"][date].append(
37
+ {
38
+ "activity": activity,
39
+ "line_geojson": make_geojson_from_time_series(time_series),
40
+ }
41
+ )
42
+ return render_template("home.html.j2", **context)
43
+
44
+
45
+ def _distance_last_30_days_meta_plot(meta: pd.DataFrame, kind_scale: alt.Scale) -> str:
46
+ before_30_days = pd.to_datetime(
47
+ datetime.datetime.now() - datetime.timedelta(days=31)
48
+ )
49
+ return (
50
+ alt.Chart(
51
+ meta.loc[meta["start"] > before_30_days],
52
+ width=700,
53
+ height=200,
54
+ title="Distance per day",
55
+ )
56
+ .mark_bar()
57
+ .encode(
58
+ alt.X("yearmonthdate(start)", title="Date"),
59
+ alt.Y("sum(distance_km)", title="Distance / km"),
60
+ alt.Color("kind", scale=kind_scale, title="Kind"),
61
+ [
62
+ alt.Tooltip("yearmonthdate(start)", title="Date"),
63
+ alt.Tooltip("kind", title="Kind"),
64
+ alt.Tooltip("sum(distance_km)", format=".1f", title="Distance / km"),
65
+ ],
66
+ )
67
+ .to_json(format="vega")
68
+ )
@@ -3,10 +3,10 @@ import pandas as pd
3
3
  from flask import Blueprint
4
4
  from flask import render_template
5
5
 
6
- from geo_activity_playground.core.activities import ActivityRepository
7
- from geo_activity_playground.core.config import Config
8
- from geo_activity_playground.core.summary_stats import get_equipment_use_table
9
- from geo_activity_playground.webui.plot_util import make_kind_scale
6
+ from ...core.activities import ActivityRepository
7
+ from ...core.config import Config
8
+ from ...core.summary_stats import get_equipment_use_table
9
+ from ..plot_util import make_kind_scale
10
10
 
11
11
 
12
12
  def make_equipment_blueprint(
@@ -20,6 +20,38 @@ def make_equipment_blueprint(
20
20
  repository.meta, config.equipment_offsets
21
21
  )
22
22
 
23
+ # Prepare data for the stacked area chart
24
+ activities = repository.meta
25
+ activities["month"] = (
26
+ activities["start"].dt.to_period("M").apply(lambda r: r.start_time)
27
+ )
28
+ monthly_data = (
29
+ activities.groupby(["month", "equipment"])
30
+ .agg(total_distance=("distance_km", "sum"))
31
+ .reset_index()
32
+ )
33
+
34
+ stacked_area_chart = (
35
+ alt.Chart(
36
+ monthly_data, height=300, width=1200, title="Monthly Equipment Usage"
37
+ )
38
+ .mark_area()
39
+ .encode(
40
+ x=alt.X("month:T", title="Month"),
41
+ y=alt.Y("total_distance:Q", title="Total Kilometers per Month"),
42
+ color=alt.Color("equipment:N", title="Equipment"),
43
+ tooltip=[
44
+ alt.Tooltip("month:T", title="Date"), # Add the date to the tooltip
45
+ alt.Tooltip("equipment:N", title="Equipment"),
46
+ alt.Tooltip(
47
+ "total_distance:Q", format=".0f", title="Total Distance"
48
+ ),
49
+ ],
50
+ )
51
+ .interactive()
52
+ .to_json(format="vega") # Specify format="vega"
53
+ )
54
+
23
55
  equipment_variables = {}
24
56
  for equipment in equipment_summary["equipment"]:
25
57
  selection = repository.meta.loc[repository.meta["equipment"] == equipment]
@@ -115,6 +147,7 @@ def make_equipment_blueprint(
115
147
  variables = {
116
148
  "equipment_variables": equipment_variables,
117
149
  "equipment_summary": equipment_summary.to_dict(orient="records"),
150
+ "stacked_area_chart": stacked_area_chart,
118
151
  }
119
152
 
120
153
  return render_template("equipment/index.html.j2", **variables)
@@ -1,62 +1,57 @@
1
1
  import datetime
2
2
  import itertools
3
- import pickle
3
+ import logging
4
4
 
5
5
  import altair as alt
6
6
  import geojson
7
7
  import matplotlib
8
8
  import numpy as np
9
9
  import pandas as pd
10
+ from flask import Blueprint
10
11
  from flask import flash
11
-
12
- from geo_activity_playground.core.activities import ActivityRepository
13
- from geo_activity_playground.core.config import ConfigAccessor
14
- from geo_activity_playground.core.coordinates import Bounds
15
- from geo_activity_playground.core.tiles import compute_tile
16
- from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
17
- from geo_activity_playground.explorer.grid_file import get_border_tiles
18
- from geo_activity_playground.explorer.grid_file import logger
19
- from geo_activity_playground.explorer.grid_file import make_explorer_rectangle
20
- from geo_activity_playground.explorer.grid_file import make_explorer_tile
21
- from geo_activity_playground.explorer.grid_file import make_grid_file_geojson
22
- from geo_activity_playground.explorer.grid_file import make_grid_file_gpx
23
- from geo_activity_playground.explorer.grid_file import make_grid_points
24
- from geo_activity_playground.explorer.tile_visits import compute_tile_evolution
25
- from geo_activity_playground.explorer.tile_visits import TileEvolutionState
26
- from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
27
-
12
+ from flask import redirect
13
+ from flask import render_template
14
+ from flask import Response
15
+ from flask import url_for
16
+
17
+ from ...core.activities import ActivityRepository
18
+ from ...core.config import ConfigAccessor
19
+ from ...core.coordinates import Bounds
20
+ from ...core.tiles import compute_tile
21
+ from ...core.tiles import get_tile_upper_left_lat_lon
22
+ from ...explorer.grid_file import get_border_tiles
23
+ from ...explorer.grid_file import make_explorer_rectangle
24
+ from ...explorer.grid_file import make_explorer_tile
25
+ from ...explorer.grid_file import make_grid_file_geojson
26
+ from ...explorer.grid_file import make_grid_file_gpx
27
+ from ...explorer.grid_file import make_grid_points
28
+ from ...explorer.tile_visits import compute_tile_evolution
29
+ from ...explorer.tile_visits import TileEvolutionState
30
+ from ...explorer.tile_visits import TileVisitAccessor
31
+ from ..authenticator import Authenticator
32
+ from ..authenticator import needs_authentication
28
33
 
29
34
  alt.data_transformers.enable("vegafusion")
30
35
 
36
+ logger = logging.getLogger(__name__)
31
37
 
32
- class ExplorerController:
33
- def __init__(
34
- self,
35
- repository: ActivityRepository,
36
- tile_visit_accessor: TileVisitAccessor,
37
- config_accessor: ConfigAccessor,
38
- ) -> None:
39
- self._repository = repository
40
- self._tile_visit_accessor = tile_visit_accessor
41
- self._config_accessor = config_accessor
42
38
 
43
- def enable_zoom_level(self, zoom: int) -> None:
44
- if 0 <= zoom <= 19:
45
- self._config_accessor().explorer_zoom_levels.append(zoom)
46
- self._config_accessor().explorer_zoom_levels.sort()
47
- self._config_accessor.save()
48
- compute_tile_evolution(self._tile_visit_accessor, self._config_accessor())
49
- flash(f"Enabled {zoom=} for explorer tiles.", category="success")
50
- else:
51
- flash(f"{zoom=} is not valid, must be between 0 and 19.", category="danger")
52
-
53
- def render(self, zoom: int) -> dict:
54
- if zoom not in self._config_accessor().explorer_zoom_levels:
39
+ def make_explorer_blueprint(
40
+ authenticator: Authenticator,
41
+ repository: ActivityRepository,
42
+ tile_visit_accessor: TileVisitAccessor,
43
+ config_accessor: ConfigAccessor,
44
+ ) -> Blueprint:
45
+ blueprint = Blueprint("explorer", __name__, template_folder="templates")
46
+
47
+ @blueprint.route("/<int:zoom>")
48
+ def map(zoom: int):
49
+ if zoom not in config_accessor().explorer_zoom_levels:
55
50
  return {"zoom_level_not_generated": zoom}
56
51
 
57
- tile_evolution_states = self._tile_visit_accessor.tile_state["evolution_state"]
58
- tile_visits = self._tile_visit_accessor.tile_state["tile_visits"]
59
- tile_histories = self._tile_visit_accessor.tile_state["tile_history"]
52
+ tile_evolution_states = tile_visit_accessor.tile_state["evolution_state"]
53
+ tile_visits = tile_visit_accessor.tile_state["tile_visits"]
54
+ tile_histories = tile_visit_accessor.tile_state["tile_history"]
60
55
 
61
56
  medians = tile_histories[zoom].median()
62
57
  median_lat, median_lon = get_tile_upper_left_lat_lon(
@@ -64,10 +59,10 @@ class ExplorerController:
64
59
  )
65
60
 
66
61
  explored = get_three_color_tiles(
67
- tile_visits[zoom], self._repository, tile_evolution_states[zoom], zoom
62
+ tile_visits[zoom], repository, tile_evolution_states[zoom], zoom
68
63
  )
69
64
 
70
- return {
65
+ context = {
71
66
  "center": {
72
67
  "latitude": median_lat,
73
68
  "longitude": median_lon,
@@ -89,35 +84,74 @@ class ExplorerController:
89
84
  ),
90
85
  "zoom": zoom,
91
86
  }
87
+ return render_template("explorer/index.html.j2", **context)
88
+
89
+ @blueprint.route("/enable-zoom-level/<int:zoom>")
90
+ @needs_authentication(authenticator)
91
+ def enable_zoom_level(zoom: int):
92
+ if 0 <= zoom <= 19:
93
+ config_accessor().explorer_zoom_levels.append(zoom)
94
+ config_accessor().explorer_zoom_levels.sort()
95
+ config_accessor.save()
96
+ compute_tile_evolution(tile_visit_accessor, config_accessor())
97
+ flash(f"Enabled {zoom=} for explorer tiles.", category="success")
98
+ else:
99
+ flash(f"{zoom=} is not valid, must be between 0 and 19.", category="danger")
100
+ return redirect(url_for(".map", zoom=zoom))
92
101
 
93
- def export_missing_tiles(self, zoom, north, east, south, west, suffix: str) -> str:
102
+ @blueprint.route(
103
+ "/<int:zoom>/<float:north>/<float:east>/<float:south>/<float:west>/explored.<suffix>"
104
+ )
105
+ def download(
106
+ zoom: int, north: float, east: float, south: float, west: float, suffix: str
107
+ ):
94
108
  x1, y1 = compute_tile(north, west, zoom)
95
109
  x2, y2 = compute_tile(south, east, zoom)
96
110
  tile_bounds = Bounds(x1, y1, x2 + 2, y2 + 2)
97
111
 
98
- tile_histories = self._tile_visit_accessor.tile_state["tile_history"]
112
+ tile_histories = tile_visit_accessor.tile_state["tile_history"]
99
113
  tiles = tile_histories[zoom]
100
114
  points = get_border_tiles(tiles, zoom, tile_bounds)
101
115
  if suffix == "geojson":
102
- return make_grid_file_geojson(points)
116
+ result = make_grid_file_geojson(points)
103
117
  elif suffix == "gpx":
104
- return make_grid_file_gpx(points)
118
+ result = make_grid_file_gpx(points)
119
+
120
+ mimetypes = {"geojson": "application/json", "gpx": "application/xml"}
121
+ return Response(
122
+ result,
123
+ mimetype=mimetypes[suffix],
124
+ headers={"Content-disposition": "attachment"},
125
+ )
105
126
 
106
- def export_explored_tiles(self, zoom, north, east, south, west, suffix: str) -> str:
127
+ @blueprint.route(
128
+ "/<int:zoom>/<float:north>/<float:east>/<float:south>/<float:west>/missing.<suffix>"
129
+ )
130
+ def missing(
131
+ zoom: int, north: float, east: float, south: float, west: float, suffix: str
132
+ ):
107
133
  x1, y1 = compute_tile(north, west, zoom)
108
134
  x2, y2 = compute_tile(south, east, zoom)
109
135
  tile_bounds = Bounds(x1, y1, x2 + 2, y2 + 2)
110
136
 
111
- tile_visits = self._tile_visit_accessor.tile_state["tile_visits"]
137
+ tile_visits = tile_visit_accessor.tile_state["tile_visits"]
112
138
  tiles = tile_visits[zoom]
113
139
  points = make_grid_points(
114
140
  (tile for tile in tiles.keys() if tile_bounds.contains(*tile)), zoom
115
141
  )
116
142
  if suffix == "geojson":
117
- return make_grid_file_geojson(points)
143
+ result = make_grid_file_geojson(points)
118
144
  elif suffix == "gpx":
119
- return make_grid_file_gpx(points)
120
- ...
145
+ result = make_grid_file_gpx(points)
146
+
147
+ mimetypes = {"geojson": "application/json", "gpx": "application/xml"}
148
+ return Response(
149
+ result,
150
+ mimetype=mimetypes[suffix],
151
+ headers={"Content-disposition": "attachment"},
152
+ )
153
+
154
+ return blueprint
121
155
 
122
156
 
123
157
  def get_three_color_tiles(
@@ -0,0 +1,233 @@
1
+ import io
2
+ import logging
3
+ import pathlib
4
+
5
+ import matplotlib.pylab as pl
6
+ import numpy as np
7
+ from flask import Blueprint
8
+ from flask import render_template
9
+ from flask import request
10
+ from flask import Response
11
+ from PIL import Image
12
+ from PIL import ImageDraw
13
+
14
+ from ...core.activities import ActivityRepository
15
+ from ...core.config import Config
16
+ from ...core.meta_search import apply_search_query
17
+ from ...core.meta_search import SearchQuery
18
+ from ...core.raster_map import convert_to_grayscale
19
+ from ...core.raster_map import GeoBounds
20
+ from ...core.raster_map import get_sensible_zoom_level
21
+ from ...core.raster_map import get_tile
22
+ from ...core.raster_map import OSM_TILE_SIZE
23
+ from ...core.raster_map import PixelBounds
24
+ from ...core.tasks import work_tracker
25
+ from ...core.tiles import get_tile_upper_left_lat_lon
26
+ from ...explorer.tile_visits import TileVisitAccessor
27
+ from ..search_util import search_query_from_form
28
+ from ..search_util import SearchQueryHistory
29
+ from .explorer_blueprint import bounding_box_for_biggest_cluster
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def make_heatmap_blueprint(
35
+ repository: ActivityRepository,
36
+ tile_visit_accessor: TileVisitAccessor,
37
+ config: Config,
38
+ search_query_history: SearchQueryHistory,
39
+ ) -> Blueprint:
40
+ blueprint = Blueprint("heatmap", __name__, template_folder="templates")
41
+
42
+ tile_histories = tile_visit_accessor.tile_state["tile_history"]
43
+ tile_evolution_states = tile_visit_accessor.tile_state["evolution_state"]
44
+ tile_visits = tile_visit_accessor.tile_state["tile_visits"]
45
+ activities_per_tile = tile_visit_accessor.tile_state["activities_per_tile"]
46
+
47
+ @blueprint.route("/")
48
+ def index():
49
+ query = search_query_from_form(request.args)
50
+ search_query_history.register_query(query)
51
+
52
+ zoom = 14
53
+ tiles = tile_histories[zoom]
54
+ medians = tiles.median(skipna=True)
55
+ median_lat, median_lon = get_tile_upper_left_lat_lon(
56
+ medians["tile_x"], medians["tile_y"], zoom
57
+ )
58
+ cluster_state = tile_evolution_states[zoom]
59
+
60
+ context = {
61
+ "center": {
62
+ "latitude": median_lat,
63
+ "longitude": median_lon,
64
+ "bbox": (
65
+ bounding_box_for_biggest_cluster(
66
+ cluster_state.clusters.values(), zoom
67
+ )
68
+ if len(cluster_state.memberships) > 0
69
+ else {}
70
+ ),
71
+ },
72
+ "extra_args": query.to_url_str(),
73
+ "query": query.to_jinja(),
74
+ }
75
+
76
+ return render_template("heatmap/index.html.j2", **context)
77
+
78
+ @blueprint.route("/tile/<int:z>/<int:x>/<int:y>.png")
79
+ def tile(x: int, y: int, z: int):
80
+ query = search_query_from_form(request.args)
81
+ f = io.BytesIO()
82
+ pl.imsave(
83
+ f,
84
+ _render_tile_image(x, y, z, query, config, repository, activities_per_tile),
85
+ format="png",
86
+ )
87
+ return Response(
88
+ bytes(f.getbuffer()),
89
+ mimetype="image/png",
90
+ )
91
+
92
+ @blueprint.route(
93
+ "/download/<float:north>/<float:east>/<float:south>/<float:west>/heatmap.png"
94
+ )
95
+ def download(north: float, east: float, south: float, west: float):
96
+ query = search_query_from_form(request.args)
97
+ geo_bounds = GeoBounds(south, west, north, east)
98
+ tile_bounds = get_sensible_zoom_level(geo_bounds, (4000, 4000))
99
+ pixel_bounds = PixelBounds.from_tile_bounds(tile_bounds)
100
+
101
+ background = np.zeros((*pixel_bounds.shape, 3))
102
+ for x in range(tile_bounds.x1, tile_bounds.x2):
103
+ for y in range(tile_bounds.y1, tile_bounds.y2):
104
+ i = y - tile_bounds.y1
105
+ j = x - tile_bounds.x1
106
+
107
+ background[
108
+ i * OSM_TILE_SIZE : (i + 1) * OSM_TILE_SIZE,
109
+ j * OSM_TILE_SIZE : (j + 1) * OSM_TILE_SIZE,
110
+ :,
111
+ ] = _render_tile_image(
112
+ x,
113
+ y,
114
+ tile_bounds.zoom,
115
+ query,
116
+ config,
117
+ repository,
118
+ activities_per_tile,
119
+ )
120
+
121
+ f = io.BytesIO()
122
+ pl.imsave(f, background, format="png")
123
+ return Response(
124
+ bytes(f.getbuffer()),
125
+ mimetype="image/png",
126
+ headers={"Content-disposition": 'attachment; filename="heatmap.png"'},
127
+ )
128
+
129
+ return blueprint
130
+
131
+
132
+ def _get_counts(
133
+ x: int,
134
+ y: int,
135
+ z: int,
136
+ query: SearchQuery,
137
+ repository: ActivityRepository,
138
+ activities_per_tile: dict[int, dict[tuple[int, int], set[int]]],
139
+ ) -> np.ndarray:
140
+ tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
141
+ tile_counts = np.zeros(tile_pixels, dtype=np.int32)
142
+ if not query.active:
143
+ tile_count_cache_path = pathlib.Path(f"Cache/Heatmap/{z}/{x}/{y}.npy")
144
+ if tile_count_cache_path.exists():
145
+ try:
146
+ tile_counts = np.load(tile_count_cache_path)
147
+ except ValueError:
148
+ logger.warning(
149
+ f"Heatmap count file {tile_count_cache_path} is corrupted, deleting."
150
+ )
151
+ tile_count_cache_path.unlink()
152
+ tile_counts = np.zeros(tile_pixels, dtype=np.int32)
153
+ tile_count_cache_path.parent.mkdir(parents=True, exist_ok=True)
154
+ activity_ids = activities_per_tile[z].get((x, y), set())
155
+
156
+ with work_tracker(
157
+ tile_count_cache_path.with_suffix(".json")
158
+ ) as parsed_activities:
159
+ if parsed_activities - activity_ids:
160
+ logger.warning(
161
+ f"Resetting heatmap cache for {x=}/{y=}/{z=} because activities have been removed."
162
+ )
163
+ tile_counts = np.zeros(tile_pixels, dtype=np.int32)
164
+ parsed_activities.clear()
165
+ for activity_id in activity_ids:
166
+ if activity_id in parsed_activities:
167
+ continue
168
+ parsed_activities.add(activity_id)
169
+ time_series = repository.get_time_series(activity_id)
170
+ for _, group in time_series.groupby("segment_id"):
171
+ xy_pixels = (
172
+ np.array([group["x"] * 2**z - x, group["y"] * 2**z - y]).T
173
+ * OSM_TILE_SIZE
174
+ )
175
+ im = Image.new("L", tile_pixels)
176
+ draw = ImageDraw.Draw(im)
177
+ pixels = list(map(int, xy_pixels.flatten()))
178
+ draw.line(pixels, fill=1, width=max(3, 6 * (z - 17)))
179
+ aim = np.array(im)
180
+ tile_counts += aim
181
+ tmp_path = tile_count_cache_path.with_suffix(".tmp.npy")
182
+ np.save(tmp_path, tile_counts)
183
+ tile_count_cache_path.unlink(missing_ok=True)
184
+ tmp_path.rename(tile_count_cache_path)
185
+ else:
186
+ activities = apply_search_query(repository.meta, query)
187
+ activity_ids = activities_per_tile[z].get((x, y), set())
188
+ for activity_id in activity_ids:
189
+ if activity_id not in activities["id"]:
190
+ continue
191
+ time_series = repository.get_time_series(activity_id)
192
+ for _, group in time_series.groupby("segment_id"):
193
+ xy_pixels = (
194
+ np.array([group["x"] * 2**z - x, group["y"] * 2**z - y]).T
195
+ * OSM_TILE_SIZE
196
+ )
197
+ im = Image.new("L", tile_pixels)
198
+ draw = ImageDraw.Draw(im)
199
+ pixels = list(map(int, xy_pixels.flatten()))
200
+ draw.line(pixels, fill=1, width=max(3, 6 * (z - 17)))
201
+ aim = np.array(im)
202
+ tile_counts += aim
203
+ return tile_counts
204
+
205
+
206
+ def _render_tile_image(
207
+ x: int,
208
+ y: int,
209
+ z: int,
210
+ query: SearchQuery,
211
+ config: Config,
212
+ repository: ActivityRepository,
213
+ activities_per_tile: dict[int, dict[tuple[int, int], set[int]]],
214
+ ) -> np.ndarray:
215
+ tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
216
+ tile_counts = np.zeros(tile_pixels)
217
+ tile_counts += _get_counts(x, y, z, query, repository, activities_per_tile)
218
+
219
+ tile_counts = np.sqrt(tile_counts) / 5
220
+ tile_counts[tile_counts > 1.0] = 1.0
221
+
222
+ cmap = pl.get_cmap(config.color_scheme_for_heatmap)
223
+ data_color = cmap(tile_counts)
224
+ data_color[data_color == cmap(0.0)] = 0.0 # remove background color
225
+
226
+ map_tile = np.array(get_tile(z, x, y, config.map_tile_url)) / 255
227
+ map_tile = convert_to_grayscale(map_tile)
228
+ map_tile = 1.0 - map_tile # invert colors
229
+ for c in range(3):
230
+ map_tile[:, :, c] = (1.0 - data_color[:, :, c]) * map_tile[
231
+ :, :, c
232
+ ] + data_color[:, :, c]
233
+ return map_tile
@@ -1,22 +1,18 @@
1
1
  import urllib.parse
2
2
  from functools import reduce
3
3
 
4
- import dateutil.parser
5
4
  from flask import Blueprint
6
- from flask import flash
7
5
  from flask import redirect
8
6
  from flask import render_template
9
7
  from flask import request
10
- from flask import Response
11
8
 
12
- from ..core.activities import ActivityRepository
13
- from geo_activity_playground.core.config import ConfigAccessor
14
- from geo_activity_playground.core.meta_search import apply_search_query
15
- from geo_activity_playground.core.meta_search import SearchQuery
16
- from geo_activity_playground.webui.authenticator import Authenticator
17
- from geo_activity_playground.webui.authenticator import needs_authentication
18
- from geo_activity_playground.webui.search_util import search_query_from_form
19
- from geo_activity_playground.webui.search_util import SearchQueryHistory
9
+ from ...core.activities import ActivityRepository
10
+ from ...core.config import ConfigAccessor
11
+ from ...core.meta_search import apply_search_query
12
+ from ..authenticator import Authenticator
13
+ from ..authenticator import needs_authentication
14
+ from ..search_util import search_query_from_form
15
+ from ..search_util import SearchQueryHistory
20
16
 
21
17
 
22
18
  def reduce_or(selections):