geo-activity-playground 0.35.1__py3-none-any.whl → 0.36.1__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 (36) hide show
  1. geo_activity_playground/core/activities.py +21 -13
  2. geo_activity_playground/core/raster_map.py +9 -13
  3. geo_activity_playground/importers/directory.py +6 -15
  4. geo_activity_playground/webui/app.py +27 -35
  5. geo_activity_playground/webui/{auth/blueprint.py → auth_blueprint.py} +1 -1
  6. geo_activity_playground/webui/{eddington/controller.py → eddington_blueprint.py} +17 -13
  7. geo_activity_playground/webui/heatmap/blueprint.py +36 -10
  8. geo_activity_playground/webui/heatmap/heatmap_controller.py +142 -60
  9. geo_activity_playground/webui/heatmap/templates/heatmap/index.html.j2 +30 -12
  10. geo_activity_playground/webui/{search/blueprint.py → search_blueprint.py} +1 -1
  11. geo_activity_playground/webui/settings/blueprint.py +1 -2
  12. geo_activity_playground/webui/square_planner_blueprint.py +118 -0
  13. geo_activity_playground/webui/{summary/controller.py → summary_blueprint.py} +23 -23
  14. geo_activity_playground/webui/templates/page.html.j2 +17 -11
  15. geo_activity_playground/webui/tile_blueprint.py +42 -0
  16. geo_activity_playground/webui/upload_blueprint.py +1 -3
  17. {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.1.dist-info}/METADATA +1 -1
  18. {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.1.dist-info}/RECORD +26 -34
  19. geo_activity_playground/webui/eddington/__init__.py +0 -0
  20. geo_activity_playground/webui/eddington/blueprint.py +0 -16
  21. geo_activity_playground/webui/square_planner/__init__.py +0 -0
  22. geo_activity_playground/webui/square_planner/blueprint.py +0 -38
  23. geo_activity_playground/webui/square_planner/controller.py +0 -101
  24. geo_activity_playground/webui/summary/__init__.py +0 -0
  25. geo_activity_playground/webui/summary/blueprint.py +0 -14
  26. geo_activity_playground/webui/tile/__init__.py +0 -0
  27. geo_activity_playground/webui/tile/blueprint.py +0 -29
  28. geo_activity_playground/webui/tile/controller.py +0 -36
  29. /geo_activity_playground/webui/{auth/templates → templates}/auth/index.html.j2 +0 -0
  30. /geo_activity_playground/webui/{eddington/templates → templates}/eddington/index.html.j2 +0 -0
  31. /geo_activity_playground/webui/{search/templates → templates}/search/index.html.j2 +0 -0
  32. /geo_activity_playground/webui/{square_planner/templates → templates}/square_planner/index.html.j2 +0 -0
  33. /geo_activity_playground/webui/{summary/templates → templates}/summary/index.html.j2 +0 -0
  34. {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.1.dist-info}/LICENSE +0 -0
  35. {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.1.dist-info}/WHEEL +0 -0
  36. {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.1.dist-info}/entry_points.txt +0 -0
@@ -3,6 +3,7 @@ import functools
3
3
  import json
4
4
  import logging
5
5
  import pickle
6
+ from collections.abc import Callable
6
7
  from typing import Any
7
8
  from typing import Iterator
8
9
  from typing import Optional
@@ -180,41 +181,48 @@ def make_geojson_from_time_series(time_series: pd.DataFrame) -> str:
180
181
  return geojson.dumps(fc)
181
182
 
182
183
 
183
- def make_geojson_color_line(time_series: pd.DataFrame) -> str:
184
- speed_without_na = time_series["speed"].dropna()
185
- low = min(speed_without_na)
186
- high = max(speed_without_na)
187
- clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
184
+ def inter_quartile_range(values):
185
+ return np.quantile(values, 0.75) - np.quantile(values, 0.25)
186
+
188
187
 
188
+ def make_geojson_color_line(time_series: pd.DataFrame) -> str:
189
+ low, high, clamp_speed = _make_speed_clamp(time_series["speed"])
189
190
  cmap = matplotlib.colormaps["viridis"]
190
191
  features = [
191
192
  geojson.Feature(
192
193
  geometry=geojson.LineString(
193
194
  coordinates=[
194
195
  [row["longitude"], row["latitude"]],
195
- [next["longitude"], next["latitude"]],
196
+ [next_row["longitude"], next_row["latitude"]],
196
197
  ]
197
198
  ),
198
199
  properties={
199
- "speed": next["speed"] if np.isfinite(next["speed"]) else 0.0,
200
- "color": matplotlib.colors.to_hex(cmap(clamp_speed(next["speed"]))),
200
+ "speed": next_row["speed"] if np.isfinite(next_row["speed"]) else 0.0,
201
+ "color": matplotlib.colors.to_hex(cmap(clamp_speed(next_row["speed"]))),
201
202
  },
202
203
  )
203
204
  for _, group in time_series.groupby("segment_id")
204
- for (_, row), (_, next) in zip(group.iterrows(), group.iloc[1:].iterrows())
205
+ for (_, row), (_, next_row) in zip(group.iterrows(), group.iloc[1:].iterrows())
205
206
  ]
206
207
  feature_collection = geojson.FeatureCollection(features)
207
208
  return geojson.dumps(feature_collection)
208
209
 
209
210
 
210
211
  def make_speed_color_bar(time_series: pd.DataFrame) -> dict[str, Any]:
211
- speed_without_na = time_series["speed"].dropna()
212
- low = min(speed_without_na)
213
- high = max(speed_without_na)
212
+ low, high, clamp_speed = _make_speed_clamp(time_series["speed"])
214
213
  cmap = matplotlib.colormaps["viridis"]
215
- clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
216
214
  colors = [
217
215
  (f"{speed:.1f}", matplotlib.colors.to_hex(cmap(clamp_speed(speed))))
218
216
  for speed in np.linspace(low, high, 10)
219
217
  ]
220
218
  return {"low": low, "high": high, "colors": colors}
219
+
220
+
221
+ def _make_speed_clamp(speeds: pd.Series) -> tuple[float, float, Callable]:
222
+ speed_without_na = speeds.dropna()
223
+ low = min(speed_without_na)
224
+ high = min(
225
+ max(speed_without_na),
226
+ np.median(speed_without_na) + 1.5 * inter_quartile_range(speed_without_na),
227
+ )
228
+ return low, high, lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
@@ -42,17 +42,17 @@ class GeoBounds:
42
42
  @dataclasses.dataclass
43
43
  class TileBounds:
44
44
  zoom: int
45
- x1: int
46
- y1: int
47
- x2: int
48
- y2: int
45
+ x1: float
46
+ y1: float
47
+ x2: float
48
+ y2: float
49
49
 
50
50
  @property
51
- def width(self) -> int:
51
+ def width(self) -> float:
52
52
  return self.x2 - self.x1
53
53
 
54
54
  @property
55
- def height(self) -> int:
55
+ def height(self) -> float:
56
56
  return self.y2 - self.y1
57
57
 
58
58
 
@@ -91,12 +91,6 @@ class RasterMapImage:
91
91
  ## Converter functions ##
92
92
 
93
93
 
94
- def tile_bounds_from_geo_bounds(geo_bounds: GeoBounds) -> TileBounds:
95
- x1, y1 = compute_tile_float(geo_bounds.lat_max, geo_bounds.lon_min)
96
- x2, y2 = compute_tile_float(geo_bounds.lat_min, geo_bounds.lon_min)
97
- return TileBounds(x1, y1, x2, y2)
98
-
99
-
100
94
  def pixel_bounds_from_tile_bounds(tile_bounds: TileBounds) -> PixelBounds:
101
95
  return PixelBounds(
102
96
  int(tile_bounds.x1 * OSM_TILE_SIZE),
@@ -204,7 +198,9 @@ def map_image_from_tile_bounds(tile_bounds: TileBounds, config: Config) -> np.nd
204
198
  north_west = np.array([tile_bounds.x1, tile_bounds.y1])
205
199
  offset = north_west % 1
206
200
  tile_anchor = north_west - offset
207
- pixel_anchor = np.array([0, 0]) - np.array(offset * OSM_TILE_SIZE, dtype=np.int64)
201
+ pixel_anchor: np.ndarray = np.array([0, 0]) - np.array(
202
+ offset * OSM_TILE_SIZE, dtype=np.int64
203
+ )
208
204
 
209
205
  num_tile_x = int(np.ceil(tile_bounds.width)) + 1
210
206
  num_tile_y = int(np.ceil(tile_bounds.height)) + 1
@@ -25,7 +25,7 @@ ACTIVITY_DIR = pathlib.Path("Activities")
25
25
 
26
26
 
27
27
  def import_from_directory(
28
- metadata_extraction_regexes: list[str], num_processes: Optional[int], config: Config
28
+ metadata_extraction_regexes: list[str], config: Config
29
29
  ) -> None:
30
30
 
31
31
  activity_paths = [
@@ -63,20 +63,11 @@ def import_from_directory(
63
63
  del file_hashes[deleted_file]
64
64
  work_tracker.discard(deleted_file)
65
65
 
66
- if num_processes == 1:
67
- paths_with_errors = []
68
- for path in tqdm(new_activity_paths, desc="Parse activity metadata (serially)"):
69
- errors = _cache_single_file(path)
70
- if errors:
71
- paths_with_errors.append(errors)
72
- else:
73
- with multiprocessing.Pool(num_processes) as pool:
74
- paths_with_errors = tqdm(
75
- pool.imap(_cache_single_file, new_activity_paths),
76
- desc="Parse activity metadata (concurrently)",
77
- total=len(new_activity_paths),
78
- )
79
- paths_with_errors = [error for error in paths_with_errors if error]
66
+ paths_with_errors = []
67
+ for path in tqdm(new_activity_paths, desc="Parse activity metadata (serially)"):
68
+ errors = _cache_single_file(path)
69
+ if errors:
70
+ paths_with_errors.append(errors)
80
71
 
81
72
  for path in tqdm(new_activity_paths, desc="Collate activity metadata"):
82
73
  activity_id = get_file_hash(path)
@@ -9,34 +9,29 @@ import urllib.parse
9
9
  from flask import Flask
10
10
  from flask import render_template
11
11
 
12
- from geo_activity_playground.core.activities import ActivityRepository
13
- from geo_activity_playground.core.config import Config
14
- from geo_activity_playground.core.config import ConfigAccessor
15
- from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
16
- from geo_activity_playground.webui.activity.blueprint import make_activity_blueprint
17
- from geo_activity_playground.webui.activity.controller import ActivityController
18
- from geo_activity_playground.webui.auth.blueprint import make_auth_blueprint
19
- from geo_activity_playground.webui.authenticator import Authenticator
20
- from geo_activity_playground.webui.calendar.blueprint import make_calendar_blueprint
21
- from geo_activity_playground.webui.calendar.controller import CalendarController
22
- from geo_activity_playground.webui.eddington.blueprint import make_eddington_blueprint
23
- from geo_activity_playground.webui.eddington.controller import EddingtonController
24
- from geo_activity_playground.webui.entry_controller import EntryController
25
- from geo_activity_playground.webui.equipment.blueprint import make_equipment_blueprint
26
- from geo_activity_playground.webui.equipment.controller import EquipmentController
27
- from geo_activity_playground.webui.explorer.blueprint import make_explorer_blueprint
28
- from geo_activity_playground.webui.explorer.controller import ExplorerController
29
- from geo_activity_playground.webui.heatmap.blueprint import make_heatmap_blueprint
30
- from geo_activity_playground.webui.search.blueprint import make_search_blueprint
31
- from geo_activity_playground.webui.settings.blueprint import make_settings_blueprint
32
- from geo_activity_playground.webui.square_planner.blueprint import (
33
- make_square_planner_blueprint,
34
- )
35
- from geo_activity_playground.webui.summary.blueprint import make_summary_blueprint
36
- from geo_activity_playground.webui.summary.controller import SummaryController
37
- from geo_activity_playground.webui.tile.blueprint import make_tile_blueprint
38
- from geo_activity_playground.webui.tile.controller import TileController
39
- from geo_activity_playground.webui.upload_blueprint import make_upload_blueprint
12
+ from ..core.activities import ActivityRepository
13
+ from ..core.config import Config
14
+ from ..core.config import ConfigAccessor
15
+ from ..explorer.tile_visits import TileVisitAccessor
16
+ from .activity.blueprint import make_activity_blueprint
17
+ from .activity.controller import ActivityController
18
+ from .auth_blueprint import make_auth_blueprint
19
+ from .authenticator import Authenticator
20
+ from .calendar.blueprint import make_calendar_blueprint
21
+ from .calendar.controller import CalendarController
22
+ from .eddington_blueprint import make_eddington_blueprint
23
+ from .entry_controller import EntryController
24
+ from .equipment.blueprint import make_equipment_blueprint
25
+ from .equipment.controller import EquipmentController
26
+ from .explorer.blueprint import make_explorer_blueprint
27
+ from .explorer.controller import ExplorerController
28
+ from .heatmap.blueprint import make_heatmap_blueprint
29
+ from .search_blueprint import make_search_blueprint
30
+ from .settings.blueprint import make_settings_blueprint
31
+ from .square_planner_blueprint import make_square_planner_blueprint
32
+ from .summary_blueprint import make_summary_blueprint
33
+ from .tile_blueprint import make_tile_blueprint
34
+ from .upload_blueprint import make_upload_blueprint
40
35
 
41
36
 
42
37
  def route_start(app: Flask, repository: ActivityRepository, config: Config) -> None:
@@ -87,12 +82,9 @@ def web_ui_main(
87
82
  authenticator = Authenticator(config_accessor())
88
83
 
89
84
  config = config_accessor()
90
- summary_controller = SummaryController(repository, config)
91
- tile_controller = TileController(config)
92
85
  activity_controller = ActivityController(repository, tile_visit_accessor, config)
93
86
  calendar_controller = CalendarController(repository)
94
87
  equipment_controller = EquipmentController(repository, config)
95
- eddington_controller = EddingtonController(repository)
96
88
  explorer_controller = ExplorerController(
97
89
  repository, tile_visit_accessor, config_accessor
98
90
  )
@@ -108,7 +100,7 @@ def web_ui_main(
108
100
  make_calendar_blueprint(calendar_controller), url_prefix="/calendar"
109
101
  )
110
102
  app.register_blueprint(
111
- make_eddington_blueprint(eddington_controller), url_prefix="/eddington"
103
+ make_eddington_blueprint(repository), url_prefix="/eddington"
112
104
  )
113
105
  app.register_blueprint(
114
106
  make_equipment_blueprint(equipment_controller), url_prefix="/equipment"
@@ -125,7 +117,7 @@ def web_ui_main(
125
117
  url_prefix="/settings",
126
118
  )
127
119
  app.register_blueprint(
128
- make_square_planner_blueprint(repository, tile_visit_accessor),
120
+ make_square_planner_blueprint(tile_visit_accessor),
129
121
  url_prefix="/square-planner",
130
122
  )
131
123
  app.register_blueprint(
@@ -133,10 +125,10 @@ def web_ui_main(
133
125
  url_prefix="/search",
134
126
  )
135
127
  app.register_blueprint(
136
- make_summary_blueprint(summary_controller), url_prefix="/summary"
128
+ make_summary_blueprint(repository, config), url_prefix="/summary"
137
129
  )
138
130
 
139
- app.register_blueprint(make_tile_blueprint(tile_controller), url_prefix="/tile")
131
+ app.register_blueprint(make_tile_blueprint(config), url_prefix="/tile")
140
132
  app.register_blueprint(
141
133
  make_upload_blueprint(
142
134
  repository, tile_visit_accessor, config_accessor(), authenticator
@@ -4,7 +4,7 @@ from flask import render_template
4
4
  from flask import request
5
5
  from flask import url_for
6
6
 
7
- from geo_activity_playground.webui.authenticator import Authenticator
7
+ from .authenticator import Authenticator
8
8
 
9
9
 
10
10
  def make_auth_blueprint(authenticator: Authenticator) -> Blueprint:
@@ -1,17 +1,19 @@
1
1
  import altair as alt
2
2
  import numpy as np
3
3
  import pandas as pd
4
+ from flask import Blueprint
5
+ from flask import render_template
4
6
 
5
- from ...core.activities import ActivityRepository
7
+ from geo_activity_playground.core.activities import ActivityRepository
6
8
 
7
9
 
8
- class EddingtonController:
9
- def __init__(self, repository: ActivityRepository) -> None:
10
- self._repository = repository
10
+ def make_eddington_blueprint(repository: ActivityRepository) -> Blueprint:
11
+ blueprint = Blueprint("eddington", __name__, template_folder="templates")
11
12
 
12
- def render(self) -> dict:
13
- activities = self._repository.meta.loc[
14
- self._repository.meta["consider_for_achievements"]
13
+ @blueprint.route("/")
14
+ def index():
15
+ activities = repository.meta.loc[
16
+ repository.meta["consider_for_achievements"]
15
17
  ].copy()
16
18
  activities["day"] = [start.date() for start in activities["start"]]
17
19
 
@@ -67,11 +69,13 @@ class EddingtonController:
67
69
  .interactive()
68
70
  .to_json(format="vega")
69
71
  )
70
-
71
- return {
72
- "eddington_number": en,
73
- "logarithmic_plot": logarithmic_plot,
74
- "eddington_table": eddington.loc[
72
+ return render_template(
73
+ "eddington/index.html.j2",
74
+ eddington_number=en,
75
+ logarithmic_plot=logarithmic_plot,
76
+ eddington_table=eddington.loc[
75
77
  (eddington["distance_km"] > en) & (eddington["distance_km"] <= en + 10)
76
78
  ].to_dict(orient="records"),
77
- }
79
+ )
80
+
81
+ return blueprint
@@ -1,12 +1,13 @@
1
+ import dateutil.parser
1
2
  from flask import Blueprint
2
3
  from flask import render_template
3
4
  from flask import request
4
5
  from flask import Response
5
6
 
6
- from ...core.activities import ActivityRepository
7
- from ...explorer.tile_visits import TileVisitAccessor
8
- from .heatmap_controller import HeatmapController
7
+ from geo_activity_playground.core.activities import ActivityRepository
9
8
  from geo_activity_playground.core.config import Config
9
+ from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
10
+ from geo_activity_playground.webui.heatmap.heatmap_controller import HeatmapController
10
11
 
11
12
 
12
13
  def make_heatmap_blueprint(
@@ -21,21 +22,46 @@ def make_heatmap_blueprint(
21
22
  def index():
22
23
  return render_template(
23
24
  "heatmap/index.html.j2",
24
- **heatmap_controller.render(request.args.getlist("kind"))
25
+ **heatmap_controller.render(
26
+ [int(k) for k in request.args.getlist("kind")],
27
+ request.args.get(
28
+ "date-start", type=dateutil.parser.parse, default=None
29
+ ),
30
+ request.args.get("date-end", type=dateutil.parser.parse, default=None),
31
+ )
25
32
  )
26
33
 
27
- @blueprint.route("/tile/<z>/<x>/<y>/<kinds>.png")
28
- def tile(x: str, y: str, z: str, kinds: str):
34
+ @blueprint.route("/tile/<int:z>/<int:x>/<int:y>.png")
35
+ def tile(x: int, y: int, z: int):
29
36
  return Response(
30
- heatmap_controller.render_tile(int(x), int(y), int(z), kinds.split(";")),
37
+ heatmap_controller.render_tile(
38
+ x,
39
+ y,
40
+ z,
41
+ [int(k) for k in request.args.getlist("kind")],
42
+ request.args.get(
43
+ "date-start", type=dateutil.parser.parse, default=None
44
+ ),
45
+ request.args.get("date-end", type=dateutil.parser.parse, default=None),
46
+ ),
31
47
  mimetype="image/png",
32
48
  )
33
49
 
34
- @blueprint.route("/download/<north>/<east>/<south>/<west>/<kinds>")
35
- def download(north: str, east: str, south: str, west: str, kinds: str):
50
+ @blueprint.route(
51
+ "/download/<float:north>/<float:east>/<float:south>/<float:west>/heatmap.png"
52
+ )
53
+ def download(north: float, east: float, south: float, west: float):
36
54
  return Response(
37
55
  heatmap_controller.download_heatmap(
38
- float(north), float(east), float(south), float(west), kinds.split(";")
56
+ north,
57
+ east,
58
+ south,
59
+ west,
60
+ [int(k) for k in request.args.getlist("kind")],
61
+ request.args.get(
62
+ "date-start", type=dateutil.parser.parse, default=None
63
+ ),
64
+ request.args.get("date-end", type=dateutil.parser.parse, default=None),
39
65
  ),
40
66
  mimetype="image/png",
41
67
  headers={"Content-disposition": 'attachment; filename="heatmap.png"'},
@@ -1,6 +1,8 @@
1
+ import datetime
1
2
  import io
2
3
  import logging
3
4
  import pathlib
5
+ from typing import Optional
4
6
 
5
7
  import matplotlib.pylab as pl
6
8
  import numpy as np
@@ -13,6 +15,7 @@ from geo_activity_playground.core.raster_map import convert_to_grayscale
13
15
  from geo_activity_playground.core.raster_map import GeoBounds
14
16
  from geo_activity_playground.core.raster_map import get_sensible_zoom_level
15
17
  from geo_activity_playground.core.raster_map import get_tile
18
+ from geo_activity_playground.core.raster_map import OSM_TILE_SIZE
16
19
  from geo_activity_playground.core.raster_map import PixelBounds
17
20
  from geo_activity_playground.core.tasks import work_tracker
18
21
  from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
@@ -25,9 +28,6 @@ from geo_activity_playground.webui.explorer.controller import (
25
28
  logger = logging.getLogger(__name__)
26
29
 
27
30
 
28
- OSM_TILE_SIZE = 256 # OSM tile size in pixel
29
-
30
-
31
31
  class HeatmapController:
32
32
  def __init__(
33
33
  self,
@@ -48,7 +48,12 @@ class HeatmapController:
48
48
  "activities_per_tile"
49
49
  ]
50
50
 
51
- def render(self, kinds: list[str] = []) -> dict:
51
+ def render(
52
+ self,
53
+ kinds: list[int],
54
+ date_start: Optional[datetime.date],
55
+ date_end: Optional[datetime.date],
56
+ ) -> dict:
52
57
  zoom = 14
53
58
  tiles = self.tile_histories[zoom]
54
59
  medians = tiles.median(skipna=True)
@@ -60,9 +65,17 @@ class HeatmapController:
60
65
  available_kinds = sorted(self._repository.meta["kind"].unique())
61
66
 
62
67
  if not kinds:
63
- kinds = available_kinds
68
+ kinds = list(range(len(available_kinds)))
69
+
70
+ extra_args = []
71
+ if date_start is not None:
72
+ extra_args.append(f"date-start={date_start.isoformat()}")
73
+ if date_end is not None:
74
+ extra_args.append(f"date-end={date_end.isoformat()}")
75
+ for kind in kinds:
76
+ extra_args.append(f"kind={kind}")
64
77
 
65
- return {
78
+ values = {
66
79
  "center": {
67
80
  "latitude": median_lat,
68
81
  "longitude": median_lon,
@@ -76,69 +89,117 @@ class HeatmapController:
76
89
  },
77
90
  "kinds": kinds,
78
91
  "available_kinds": available_kinds,
79
- "kinds_str": ";".join(kinds),
92
+ "extra_args": "&".join(extra_args),
80
93
  }
94
+ if date_start is not None:
95
+ values["date_start"] = date_start.date().isoformat()
96
+ if date_end is not None:
97
+ values["date_end"] = date_end.date().isoformat()
81
98
 
82
- def _get_counts(self, x: int, y: int, z: int, kind: str) -> np.ndarray:
99
+ return values
100
+
101
+ def _get_counts(
102
+ self,
103
+ x: int,
104
+ y: int,
105
+ z: int,
106
+ kind: str,
107
+ date_start: Optional[datetime.date],
108
+ date_end: Optional[datetime.date],
109
+ ) -> np.ndarray:
83
110
  tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
84
- tile_count_cache_path = pathlib.Path(f"Cache/Heatmap/{kind}/{z}/{x}/{y}.npy")
85
- if tile_count_cache_path.exists():
86
- try:
87
- tile_counts = np.load(tile_count_cache_path)
88
- except ValueError:
89
- logger.warning(
90
- f"Heatmap count file {tile_count_cache_path} is corrupted, deleting."
91
- )
92
- tile_count_cache_path.unlink()
93
- tile_counts = np.zeros(tile_pixels, dtype=np.int32)
94
- else:
95
- tile_counts = np.zeros(tile_pixels, dtype=np.int32)
96
- tile_count_cache_path.parent.mkdir(parents=True, exist_ok=True)
97
- activity_ids = self.activities_per_tile[z].get((x, y), set())
98
- activity_ids_kind = set()
99
- for activity_id in activity_ids:
100
- activity = self._repository.get_activity_by_id(activity_id)
101
- if activity["kind"] == kind:
102
- activity_ids_kind.add(activity_id)
103
- if activity_ids_kind:
104
- with work_tracker(
105
- tile_count_cache_path.with_suffix(".json")
106
- ) as parsed_activities:
107
- if parsed_activities - activity_ids_kind:
111
+ tile_counts = np.zeros(tile_pixels, dtype=np.int32)
112
+ if date_start is None and date_end is None:
113
+ tile_count_cache_path = pathlib.Path(
114
+ f"Cache/Heatmap/{kind}/{z}/{x}/{y}.npy"
115
+ )
116
+ if tile_count_cache_path.exists():
117
+ try:
118
+ tile_counts = np.load(tile_count_cache_path)
119
+ except ValueError:
108
120
  logger.warning(
109
- f"Resetting heatmap cache for {kind=}/{x=}/{y=}/{z=} because activities have been removed."
121
+ f"Heatmap count file {tile_count_cache_path} is corrupted, deleting."
110
122
  )
123
+ tile_count_cache_path.unlink()
111
124
  tile_counts = np.zeros(tile_pixels, dtype=np.int32)
112
- parsed_activities.clear()
113
- for activity_id in activity_ids_kind:
114
- if activity_id in parsed_activities:
115
- continue
116
- parsed_activities.add(activity_id)
117
- time_series = self._repository.get_time_series(activity_id)
118
- for _, group in time_series.groupby("segment_id"):
119
- xy_pixels = (
120
- np.array([group["x"] * 2**z - x, group["y"] * 2**z - y]).T
121
- * OSM_TILE_SIZE
125
+ tile_count_cache_path.parent.mkdir(parents=True, exist_ok=True)
126
+ activity_ids = self.activities_per_tile[z].get((x, y), set())
127
+ activity_ids_kind = set()
128
+ for activity_id in activity_ids:
129
+ activity = self._repository.get_activity_by_id(activity_id)
130
+ if activity["kind"] == kind:
131
+ activity_ids_kind.add(activity_id)
132
+ if activity_ids_kind:
133
+ with work_tracker(
134
+ tile_count_cache_path.with_suffix(".json")
135
+ ) as parsed_activities:
136
+ if parsed_activities - activity_ids_kind:
137
+ logger.warning(
138
+ f"Resetting heatmap cache for {kind=}/{x=}/{y=}/{z=} because activities have been removed."
122
139
  )
123
- im = Image.new("L", tile_pixels)
124
- draw = ImageDraw.Draw(im)
125
- pixels = list(map(int, xy_pixels.flatten()))
126
- draw.line(pixels, fill=1, width=max(3, 6 * (z - 17)))
127
- aim = np.array(im)
128
- tile_counts += aim
129
- tmp_path = tile_count_cache_path.with_suffix(".tmp.npy")
130
- np.save(tmp_path, tile_counts)
131
- tile_count_cache_path.unlink(missing_ok=True)
132
- tmp_path.rename(tile_count_cache_path)
140
+ tile_counts = np.zeros(tile_pixels, dtype=np.int32)
141
+ parsed_activities.clear()
142
+ for activity_id in activity_ids_kind:
143
+ if activity_id in parsed_activities:
144
+ continue
145
+ parsed_activities.add(activity_id)
146
+ time_series = self._repository.get_time_series(activity_id)
147
+ for _, group in time_series.groupby("segment_id"):
148
+ xy_pixels = (
149
+ np.array(
150
+ [group["x"] * 2**z - x, group["y"] * 2**z - y]
151
+ ).T
152
+ * OSM_TILE_SIZE
153
+ )
154
+ im = Image.new("L", tile_pixels)
155
+ draw = ImageDraw.Draw(im)
156
+ pixels = list(map(int, xy_pixels.flatten()))
157
+ draw.line(pixels, fill=1, width=max(3, 6 * (z - 17)))
158
+ aim = np.array(im)
159
+ tile_counts += aim
160
+ tmp_path = tile_count_cache_path.with_suffix(".tmp.npy")
161
+ np.save(tmp_path, tile_counts)
162
+ tile_count_cache_path.unlink(missing_ok=True)
163
+ tmp_path.rename(tile_count_cache_path)
164
+ else:
165
+ activity_ids = self.activities_per_tile[z].get((x, y), set())
166
+ for activity_id in activity_ids:
167
+ activity = self._repository.get_activity_by_id(activity_id)
168
+ if not activity["kind"] == kind:
169
+ continue
170
+ if date_start is not None and activity["start"] < date_start:
171
+ continue
172
+ if date_end is not None and date_end < activity["start"]:
173
+ continue
174
+ time_series = self._repository.get_time_series(activity_id)
175
+ for _, group in time_series.groupby("segment_id"):
176
+ xy_pixels = (
177
+ np.array([group["x"] * 2**z - x, group["y"] * 2**z - y]).T
178
+ * OSM_TILE_SIZE
179
+ )
180
+ im = Image.new("L", tile_pixels)
181
+ draw = ImageDraw.Draw(im)
182
+ pixels = list(map(int, xy_pixels.flatten()))
183
+ draw.line(pixels, fill=1, width=max(3, 6 * (z - 17)))
184
+ aim = np.array(im)
185
+ tile_counts += aim
133
186
  return tile_counts
134
187
 
135
188
  def _render_tile_image(
136
- self, x: int, y: int, z: int, kinds: list[str]
189
+ self,
190
+ x: int,
191
+ y: int,
192
+ z: int,
193
+ kinds_ids: list[int],
194
+ date_start: Optional[datetime.date],
195
+ date_end: Optional[datetime.date],
137
196
  ) -> np.ndarray:
138
197
  tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
139
198
  tile_counts = np.zeros(tile_pixels)
140
- for kind in kinds:
141
- tile_counts += self._get_counts(x, y, z, kind)
199
+ available_kinds = sorted(self._repository.meta["kind"].unique())
200
+ for kind_id in kinds_ids:
201
+ kind = available_kinds[kind_id]
202
+ tile_counts += self._get_counts(x, y, z, kind, date_start, date_end)
142
203
 
143
204
  tile_counts = np.sqrt(tile_counts) / 5
144
205
  tile_counts[tile_counts > 1.0] = 1.0
@@ -156,13 +217,32 @@ class HeatmapController:
156
217
  ] + data_color[:, :, c]
157
218
  return map_tile
158
219
 
159
- def render_tile(self, x: int, y: int, z: int, kinds: list[str]) -> bytes:
220
+ def render_tile(
221
+ self,
222
+ x: int,
223
+ y: int,
224
+ z: int,
225
+ kind_ids: list[int],
226
+ date_start: Optional[datetime.date],
227
+ date_end: Optional[datetime.date],
228
+ ) -> bytes:
160
229
  f = io.BytesIO()
161
- pl.imsave(f, self._render_tile_image(x, y, z, kinds), format="png")
230
+ pl.imsave(
231
+ f,
232
+ self._render_tile_image(x, y, z, kind_ids, date_start, date_end),
233
+ format="png",
234
+ )
162
235
  return bytes(f.getbuffer())
163
236
 
164
237
  def download_heatmap(
165
- self, north: float, east: float, south: float, west: float, kinds: list[str]
238
+ self,
239
+ north: float,
240
+ east: float,
241
+ south: float,
242
+ west: float,
243
+ kind_ids: list[int],
244
+ date_start: Optional[datetime.date],
245
+ date_end: Optional[datetime.date],
166
246
  ) -> bytes:
167
247
  geo_bounds = GeoBounds(south, west, north, east)
168
248
  tile_bounds = get_sensible_zoom_level(geo_bounds, (4000, 4000))
@@ -185,7 +265,9 @@ class HeatmapController:
185
265
  i * OSM_TILE_SIZE : (i + 1) * OSM_TILE_SIZE,
186
266
  j * OSM_TILE_SIZE : (j + 1) * OSM_TILE_SIZE,
187
267
  :,
188
- ] = self._render_tile_image(x, y, tile_bounds.zoom, kinds)
268
+ ] = self._render_tile_image(
269
+ x, y, tile_bounds.zoom, kind_ids, date_start, date_end
270
+ )
189
271
 
190
272
  f = io.BytesIO()
191
273
  pl.imsave(f, background, format="png")