geo-activity-playground 0.27.1__py3-none-any.whl → 0.29.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 (40) hide show
  1. geo_activity_playground/__main__.py +1 -2
  2. geo_activity_playground/core/activities.py +3 -3
  3. geo_activity_playground/core/config.py +4 -0
  4. geo_activity_playground/core/paths.py +10 -0
  5. geo_activity_playground/core/tasks.py +7 -6
  6. geo_activity_playground/explorer/tile_visits.py +168 -133
  7. geo_activity_playground/webui/activity/controller.py +51 -14
  8. geo_activity_playground/webui/activity/templates/activity/show.html.j2 +37 -9
  9. geo_activity_playground/webui/app.py +20 -22
  10. geo_activity_playground/webui/auth/blueprint.py +27 -0
  11. geo_activity_playground/webui/auth/templates/auth/index.html.j2 +21 -0
  12. geo_activity_playground/webui/authenticator.py +46 -0
  13. geo_activity_playground/webui/entry_controller.py +8 -4
  14. geo_activity_playground/webui/equipment/controller.py +2 -1
  15. geo_activity_playground/webui/explorer/controller.py +4 -3
  16. geo_activity_playground/webui/explorer/templates/explorer/index.html.j2 +2 -0
  17. geo_activity_playground/webui/heatmap/heatmap_controller.py +20 -6
  18. geo_activity_playground/webui/plot_util.py +9 -0
  19. geo_activity_playground/webui/search/blueprint.py +20 -0
  20. geo_activity_playground/webui/settings/blueprint.py +101 -1
  21. geo_activity_playground/webui/settings/controller.py +43 -0
  22. geo_activity_playground/webui/settings/templates/settings/admin-password.html.j2 +19 -0
  23. geo_activity_playground/webui/settings/templates/settings/color-schemes.html.j2 +33 -0
  24. geo_activity_playground/webui/settings/templates/settings/index.html.j2 +27 -0
  25. geo_activity_playground/webui/settings/templates/settings/sharepic.html.j2 +22 -0
  26. geo_activity_playground/webui/square_planner/controller.py +1 -1
  27. geo_activity_playground/webui/summary/blueprint.py +3 -2
  28. geo_activity_playground/webui/summary/controller.py +20 -13
  29. geo_activity_playground/webui/templates/home.html.j2 +1 -1
  30. geo_activity_playground/webui/templates/page.html.j2 +57 -29
  31. geo_activity_playground/webui/upload/blueprint.py +7 -0
  32. geo_activity_playground/webui/upload/controller.py +4 -8
  33. geo_activity_playground/webui/upload/templates/upload/index.html.j2 +15 -31
  34. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.29.0.dist-info}/METADATA +3 -4
  35. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.29.0.dist-info}/RECORD +39 -32
  36. geo_activity_playground/webui/search_controller.py +0 -19
  37. /geo_activity_playground/webui/{templates/search.html.j2 → search/templates/search/index.html.j2} +0 -0
  38. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.29.0.dist-info}/LICENSE +0 -0
  39. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.29.0.dist-info}/WHEEL +0 -0
  40. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.29.0.dist-info}/entry_points.txt +0 -0
@@ -109,10 +109,9 @@ def make_activity_repository(
109
109
 
110
110
 
111
111
  def main_cache(basedir: pathlib.Path) -> None:
112
- repository, tile_visit_accessor, config_accessor = make_activity_repository(
112
+ (repository, tile_visit_accessor, config_accessor) = make_activity_repository(
113
113
  basedir, False
114
114
  )
115
- scan_for_activities(repository, tile_visit_accessor, config_accessor())
116
115
 
117
116
 
118
117
  if __name__ == "__main__":
@@ -128,11 +128,11 @@ class ActivityRepository:
128
128
  else:
129
129
  return None
130
130
 
131
- def get_activity_ids(self, only_achievements: bool = False) -> set[int]:
131
+ def get_activity_ids(self, only_achievements: bool = False) -> list[int]:
132
132
  if only_achievements:
133
- return set(self.meta.loc[self.meta["consider_for_achievements"]].index)
133
+ return list(self.meta.loc[self.meta["consider_for_achievements"]].index)
134
134
  else:
135
- return set(self.meta.index)
135
+ return list(self.meta.index)
136
136
 
137
137
  def iter_activities(self, new_to_old=True, dropna=False) -> Iterator[ActivityMeta]:
138
138
  direction = -1 if new_to_old else 1
@@ -21,6 +21,8 @@ logger = logging.getLogger(__name__)
21
21
  @dataclasses.dataclass
22
22
  class Config:
23
23
  birth_year: Optional[int] = None
24
+ color_scheme_for_counts: str = "viridis"
25
+ color_scheme_for_kind: str = "category10"
24
26
  equipment_offsets: dict[str, float] = dataclasses.field(default_factory=dict)
25
27
  explorer_zoom_levels: list[int] = dataclasses.field(
26
28
  default_factory=lambda: [14, 17]
@@ -33,6 +35,7 @@ class Config:
33
35
  privacy_zones: dict[str, list[list[float]]] = dataclasses.field(
34
36
  default_factory=dict
35
37
  )
38
+ sharepic_suppressed_fields: list[str] = dataclasses.field(default_factory=list)
36
39
  strava_client_id: int = 131693
37
40
  strava_client_secret: str = "0ccc0100a2c218512a7ef0cea3b0e322fb4b4365"
38
41
  strava_client_code: Optional[str] = None
@@ -51,6 +54,7 @@ class ConfigAccessor:
51
54
  return self._config
52
55
 
53
56
  def save(self) -> None:
57
+ print(self._config)
54
58
  with open(new_config_file(), "w") as f:
55
59
  json.dump(
56
60
  dataclasses.asdict(self._config),
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Paths within the playground and cache.
3
3
  """
4
+ import contextlib
4
5
  import functools
5
6
  import pathlib
6
7
  import typing
@@ -24,6 +25,15 @@ def file_wrapper(path: pathlib.Path) -> typing.Callable[[], pathlib.Path]:
24
25
  return wrapper
25
26
 
26
27
 
28
+ @contextlib.contextmanager
29
+ def atomic_open(path: pathlib.Path, mode: str):
30
+ temp_path = path.with_stem(path.stem + "-temp")
31
+ with open(temp_path, mode) as f:
32
+ yield f
33
+ path.unlink(missing_ok=True)
34
+ temp_path.rename(path)
35
+
36
+
27
37
  _cache_dir = pathlib.Path("Cache")
28
38
 
29
39
  _activity_dir = _cache_dir / "Activity"
@@ -8,6 +8,7 @@ from typing import Generic
8
8
  from typing import Sequence
9
9
  from typing import TypeVar
10
10
 
11
+ from geo_activity_playground.core.paths import atomic_open
11
12
  from geo_activity_playground.core.paths import cache_dir
12
13
 
13
14
 
@@ -24,11 +25,8 @@ def stored_object(path: pathlib.Path, default):
24
25
 
25
26
  yield payload
26
27
 
27
- temp_location = path.with_suffix(".tmp")
28
- with open(temp_location, "wb") as f:
28
+ with atomic_open(path, "wb") as f:
29
29
  pickle.dump(payload, f)
30
- path.unlink(missing_ok=True)
31
- temp_location.rename(path)
32
30
 
33
31
 
34
32
  def work_tracker_path(name: str) -> pathlib.Path:
@@ -59,8 +57,8 @@ class WorkTracker:
59
57
  else:
60
58
  self._done = set()
61
59
 
62
- def filter(self, ids: Iterable) -> set:
63
- return set(ids) - self._done
60
+ def filter(self, ids: Iterable) -> list:
61
+ return [elem for elem in ids if elem not in self._done]
64
62
 
65
63
  def mark_done(self, id: int) -> None:
66
64
  self._done.add(id)
@@ -68,6 +66,9 @@ class WorkTracker:
68
66
  def discard(self, id) -> None:
69
67
  self._done.discard(id)
70
68
 
69
+ def reset(self) -> None:
70
+ self._done = set()
71
+
71
72
  def close(self) -> None:
72
73
  with open(self._path, "wb") as f:
73
74
  pickle.dump(self._done, f)
@@ -14,6 +14,7 @@ from tqdm import tqdm
14
14
 
15
15
  from geo_activity_playground.core.activities import ActivityRepository
16
16
  from geo_activity_playground.core.config import Config
17
+ from geo_activity_playground.core.paths import atomic_open
17
18
  from geo_activity_playground.core.paths import tiles_per_time_series
18
19
  from geo_activity_playground.core.tasks import try_load_pickle
19
20
  from geo_activity_playground.core.tasks import work_tracker_path
@@ -25,139 +26,192 @@ from geo_activity_playground.core.tiles import interpolate_missing_tile
25
26
  logger = logging.getLogger(__name__)
26
27
 
27
28
 
29
+ class TileInfo(TypedDict):
30
+ activity_ids: set[int]
31
+ first_time: datetime.datetime
32
+ first_id: int
33
+ last_time: datetime.datetime
34
+ last_id: int
35
+
36
+
28
37
  class TileHistoryRow(TypedDict):
38
+ activity_id: int
29
39
  time: datetime.datetime
30
40
  tile_x: int
31
41
  tile_y: int
32
42
 
33
43
 
44
+ class TileEvolutionState:
45
+ def __init__(self) -> None:
46
+ self.num_neighbors: dict[tuple[int, int], int] = {}
47
+ self.memberships: dict[tuple[int, int], tuple[int, int]] = {}
48
+ self.clusters: dict[tuple[int, int], list[tuple[int, int]]] = {}
49
+ self.cluster_evolution = pd.DataFrame()
50
+ self.square_start = 0
51
+ self.cluster_start = 0
52
+ self.max_square_size = 0
53
+ self.visited_tiles: set[tuple[int, int]] = set()
54
+ self.square_evolution = pd.DataFrame()
55
+ self.square_x: Optional[int] = None
56
+ self.square_y: Optional[int] = None
57
+
58
+
59
+ class TileState(TypedDict):
60
+ tile_visits: dict[int, dict[tuple[int, int], TileInfo]]
61
+ tile_history: dict[int, pd.DataFrame]
62
+ activities_per_tile: dict[int, set[int]]
63
+ processed_activities: set[int]
64
+ evolution_state: dict[int, TileEvolutionState]
65
+ version: int
66
+
67
+
68
+ TILE_STATE_VERSION = 2
69
+
70
+
34
71
  class TileVisitAccessor:
35
- TILE_EVOLUTION_STATES_PATH = pathlib.Path("Cache/tile-evolution-state.pickle")
36
- TILE_HISTORIES_PATH = pathlib.Path(f"Cache/tile-history.pickle")
37
- TILE_VISITS_PATH = pathlib.Path(f"Cache/tile-visits.pickle")
38
- ACTIVITIES_PER_TILE_PATH = pathlib.Path(f"Cache/activities-per-tile.pickle")
72
+ PATH = pathlib.Path("Cache/tile-state-2.pickle")
39
73
 
40
74
  def __init__(self) -> None:
41
- self.visits: dict[int, dict[tuple[int, int], dict[str, Any]]] = try_load_pickle(
42
- self.TILE_VISITS_PATH
43
- ) or collections.defaultdict(dict)
44
- "zoom (tile_x, tile_y) tile_info"
45
-
46
- self.histories: dict[int, pd.DataFrame] = try_load_pickle(
47
- self.TILE_HISTORIES_PATH
48
- ) or collections.defaultdict(pd.DataFrame)
49
-
50
- self.states = try_load_pickle(
51
- self.TILE_EVOLUTION_STATES_PATH
52
- ) or collections.defaultdict(TileEvolutionState)
53
-
54
- self.activities_per_tile: dict[
55
- int, dict[tuple[int, int], set[int]]
56
- ] = try_load_pickle(self.ACTIVITIES_PER_TILE_PATH) or collections.defaultdict(
57
- dict
58
- )
75
+ self.tile_state: TileState = try_load_pickle(self.PATH)
76
+ if (
77
+ self.tile_state is None
78
+ or self.tile_state.get("version", None) != TILE_STATE_VERSION
79
+ ):
80
+ self.tile_state = make_tile_state()
81
+ # TODO: Reset work tracker
82
+
83
+ def reset(self) -> None:
84
+ self.tile_state = make_tile_state()
59
85
 
60
86
  def save(self) -> None:
61
- with open(self.TILE_VISITS_PATH, "wb") as f:
62
- pickle.dump(self.visits, f)
87
+ with atomic_open(self.PATH, "wb") as f:
88
+ pickle.dump(self.tile_state, f)
89
+
90
+
91
+ def make_defaultdict_dict():
92
+ return collections.defaultdict(dict)
93
+
94
+
95
+ def make_defaultdict_set():
96
+ return collections.defaultdict(set)
97
+
98
+
99
+ def make_tile_state() -> TileState:
100
+ tile_state: TileState = {
101
+ "tile_visits": collections.defaultdict(make_defaultdict_dict),
102
+ "tile_history": collections.defaultdict(pd.DataFrame),
103
+ "activities_per_tile": collections.defaultdict(make_defaultdict_set),
104
+ "processed_activities": set(),
105
+ "evolution_state": collections.defaultdict(TileEvolutionState),
106
+ "version": TILE_STATE_VERSION,
107
+ }
108
+ return tile_state
63
109
 
64
- with open(self.TILE_HISTORIES_PATH, "wb") as f:
65
- pickle.dump(self.histories, f)
66
110
 
67
- with open(self.TILE_EVOLUTION_STATES_PATH, "wb") as f:
68
- pickle.dump(self.states, f)
111
+ def _consistency_check(
112
+ repository: ActivityRepository, tile_visit_accessor: TileVisitAccessor
113
+ ) -> bool:
114
+ present_activity_ids = set(repository.get_activity_ids())
69
115
 
70
- with open(self.ACTIVITIES_PER_TILE_PATH, "wb") as f:
71
- pickle.dump(self.activities_per_tile, f)
116
+ for zoom, activities_per_tile in tile_visit_accessor.tile_state[
117
+ "activities_per_tile"
118
+ ].items():
119
+ for tile, tile_activity_ids in activities_per_tile.items():
120
+ deleted_activity_ids = tile_activity_ids - present_activity_ids
121
+ if deleted_activity_ids:
122
+ logger.info(f"Activities {deleted_activity_ids} have been deleted.")
123
+ return False
72
124
 
125
+ for zoom, tile_visits in tile_visit_accessor.tile_state["tile_visits"].items():
126
+ for tile, meta in tile_visits.items():
127
+ if meta["first_id"] not in present_activity_ids:
128
+ logger.info(f"Activity {meta['first_id']} have been deleted.")
129
+ return False
130
+ if meta["last_id"] not in present_activity_ids:
131
+ logger.info(f"Activity {meta['last_id']} have been deleted.")
132
+ return False
73
133
 
74
- def compute_tile_visits(
75
- repository: ActivityRepository, tile_visits_accessor: TileVisitAccessor
134
+ return True
135
+
136
+
137
+ def compute_tile_visits_new(
138
+ repository: ActivityRepository, tile_visit_accessor: TileVisitAccessor
76
139
  ) -> None:
77
- present_activity_ids = repository.get_activity_ids()
78
- work_tracker = WorkTracker(work_tracker_path("tile-visits"))
79
-
80
- changed_zoom_tile = collections.defaultdict(set)
81
-
82
- # Delete visits from removed activities.
83
- for zoom, activities_per_tile in tile_visits_accessor.activities_per_tile.items():
84
- for tile, activity_ids in activities_per_tile.items():
85
- deleted_ids = activity_ids - present_activity_ids
86
- if deleted_ids:
87
- logger.debug(
88
- f"Removing activities {deleted_ids} from tile {tile} at {zoom=}."
89
- )
90
- for activity_id in deleted_ids:
91
- activity_ids.remove(activity_id)
92
- work_tracker.discard(activity_id)
93
- changed_zoom_tile[zoom].add(tile)
140
+ work_tracker = WorkTracker(work_tracker_path("tile-state"))
141
+
142
+ if not _consistency_check(repository, tile_visit_accessor):
143
+ logger.warning("Need to recompute Explorer Tiles due to deleted activities.")
144
+ tile_visit_accessor.reset()
145
+ work_tracker.reset()
94
146
 
95
- # Add visits from new activities.
96
- activity_ids_to_process = work_tracker.filter(repository.get_activity_ids())
97
147
  for activity_id in tqdm(
98
- activity_ids_to_process, desc="Extract explorer tile visits"
148
+ work_tracker.filter(repository.get_activity_ids()), desc="Tile visits"
99
149
  ):
100
- for zoom in range(20):
101
- for time, tile_x, tile_y in _tiles_from_points(
102
- repository.get_time_series(activity_id), zoom
103
- ):
104
- tile = (tile_x, tile_y)
105
- if tile not in tile_visits_accessor.activities_per_tile[zoom]:
106
- tile_visits_accessor.activities_per_tile[zoom][tile] = set()
107
- tile_visits_accessor.activities_per_tile[zoom][tile].add(activity_id)
108
- changed_zoom_tile[zoom].add(tile)
150
+ _process_activity(repository, tile_visit_accessor.tile_state, activity_id)
109
151
  work_tracker.mark_done(activity_id)
110
-
111
- # Update tile visits structure.
112
- for zoom, changed_tiles in tqdm(
113
- changed_zoom_tile.items(), desc="Incorporate changes in tiles"
114
- ):
115
- soa = {"activity_id": [], "time": [], "tile_x": [], "tile_y": []}
116
-
117
- for tile in changed_tiles:
118
- activity_ids = tile_visits_accessor.activities_per_tile[zoom][tile]
119
- activities = [
120
- repository.get_activity_by_id(activity_id)
121
- for activity_id in activity_ids
122
- ]
123
- activities_to_consider = [
124
- activity
125
- for activity in activities
126
- if activity["consider_for_achievements"]
127
- ]
128
- activities_to_consider.sort(key=lambda activity: activity["start"])
129
-
130
- if activities_to_consider:
131
- tile_visits_accessor.visits[zoom][tile] = {
132
- "first_time": activities_to_consider[0]["start"],
133
- "first_id": activities_to_consider[0]["id"],
134
- "last_time": activities_to_consider[-1]["start"],
135
- "last_id": activities_to_consider[-1]["id"],
136
- "activity_ids": {
137
- activity["id"] for activity in activities_to_consider
138
- },
139
- }
140
-
141
- soa["activity_id"].append(activities_to_consider[0]["id"])
142
- soa["time"].append(activities_to_consider[0]["start"])
143
- soa["tile_x"].append(tile[0])
144
- soa["tile_y"].append(tile[1])
145
- else:
146
- if tile in tile_visits_accessor.visits[zoom]:
147
- del tile_visits_accessor.visits[zoom][tile]
148
-
149
- df = pd.DataFrame(soa)
150
- if len(df) > 0:
151
- df = pd.concat([tile_visits_accessor.histories[zoom], df])
152
- df.sort_values("time", inplace=True)
153
- tile_visits_accessor.histories[zoom] = df.groupby(
154
- ["tile_x", "tile_y"]
155
- ).head(1)
156
-
157
- tile_visits_accessor.save()
152
+ tile_visit_accessor.save()
158
153
  work_tracker.close()
159
154
 
160
155
 
156
+ def _process_activity(
157
+ repository: ActivityRepository, tile_state: TileState, activity_id: int
158
+ ) -> None:
159
+ activity = repository.get_activity_by_id(activity_id)
160
+ time_series = repository.get_time_series(activity_id)
161
+
162
+ activity_tiles = pd.DataFrame(
163
+ _tiles_from_points(time_series, 19), columns=["time", "tile_x", "tile_y"]
164
+ )
165
+ for zoom in reversed(range(20)):
166
+ activities_per_tile = tile_state["activities_per_tile"][zoom]
167
+
168
+ new_tile_history_soa = {
169
+ "activity_id": [],
170
+ "time": [],
171
+ "tile_x": [],
172
+ "tile_y": [],
173
+ }
174
+
175
+ activity_tiles = activity_tiles.groupby(["tile_x", "tile_y"]).head(1)
176
+
177
+ for time, tile in zip(
178
+ activity_tiles["time"],
179
+ zip(activity_tiles["tile_x"], activity_tiles["tile_y"]),
180
+ ):
181
+ if activity["consider_for_achievements"]:
182
+ if tile not in tile_state["tile_visits"][zoom]:
183
+ new_tile_history_soa["activity_id"].append(activity_id)
184
+ new_tile_history_soa["time"].append(time)
185
+ new_tile_history_soa["tile_x"].append(tile[0])
186
+ new_tile_history_soa["tile_y"].append(tile[1])
187
+
188
+ tile_visit = tile_state["tile_visits"][zoom][tile]
189
+ if not tile_visit:
190
+ tile_visit["activity_ids"] = {activity_id}
191
+ else:
192
+ tile_visit["activity_ids"].add(activity_id)
193
+
194
+ first_time = tile_visit.get("first_time", None)
195
+ last_time = tile_visit.get("last_time", None)
196
+ if first_time is None or time < first_time:
197
+ tile_visit["first_id"] = activity_id
198
+ tile_visit["first_time"] = time
199
+ if last_time is None or time > last_time:
200
+ tile_visit["last_id"] = activity_id
201
+ tile_visit["last_time"] = time
202
+
203
+ activities_per_tile[tile].add(activity_id)
204
+
205
+ if new_tile_history_soa["activity_id"]:
206
+ tile_state["tile_history"][zoom] = pd.concat(
207
+ [tile_state["tile_history"][zoom], pd.DataFrame(new_tile_history_soa)]
208
+ )
209
+
210
+ # Move up one layer in the quad-tree.
211
+ activity_tiles["tile_x"] //= 2
212
+ activity_tiles["tile_y"] //= 2
213
+
214
+
161
215
  def _tiles_from_points(
162
216
  time_series: pd.DataFrame, zoom: int
163
217
  ) -> Iterator[tuple[datetime.datetime, int, int]]:
@@ -181,38 +235,19 @@ def _tiles_from_points(
181
235
  yield (t1,) + interpolated
182
236
 
183
237
 
184
- class TileEvolutionState:
185
- def __init__(self) -> None:
186
- self.num_neighbors: dict[tuple[int, int], int] = {}
187
- self.memberships: dict[tuple[int, int], tuple[int, int]] = {}
188
- self.clusters: dict[tuple[int, int], list[tuple[int, int]]] = {}
189
- self.cluster_evolution = pd.DataFrame()
190
- self.square_start = 0
191
- self.cluster_start = 0
192
- self.max_square_size = 0
193
- self.visited_tiles: set[tuple[int, int]] = set()
194
- self.square_evolution = pd.DataFrame()
195
- self.square_x: Optional[int] = None
196
- self.square_y: Optional[int] = None
197
-
198
-
199
- def compute_tile_evolution(
200
- tile_visits_accessor: TileVisitAccessor, config: Config
201
- ) -> None:
238
+ def compute_tile_evolution(tile_state: TileState, config: Config) -> None:
202
239
  for zoom in config.explorer_zoom_levels:
203
240
  _compute_cluster_evolution(
204
- tile_visits_accessor.histories[zoom],
205
- tile_visits_accessor.states[zoom],
241
+ tile_state["tile_history"][zoom],
242
+ tile_state["evolution_state"][zoom],
206
243
  zoom,
207
244
  )
208
245
  _compute_square_history(
209
- tile_visits_accessor.histories[zoom],
210
- tile_visits_accessor.states[zoom],
246
+ tile_state["tile_history"][zoom],
247
+ tile_state["evolution_state"][zoom],
211
248
  zoom,
212
249
  )
213
250
 
214
- tile_visits_accessor.save()
215
-
216
251
 
217
252
  def _compute_cluster_evolution(
218
253
  tiles: pd.DataFrame, s: TileEvolutionState, zoom: int
@@ -12,6 +12,8 @@ import pandas as pd
12
12
  from PIL import Image
13
13
  from PIL import ImageDraw
14
14
 
15
+ from ...explorer.grid_file import make_grid_file_geojson
16
+ from ...explorer.grid_file import make_grid_points
15
17
  from geo_activity_playground.core.activities import ActivityMeta
16
18
  from geo_activity_playground.core.activities import ActivityRepository
17
19
  from geo_activity_playground.core.activities import make_geojson_color_line
@@ -61,12 +63,32 @@ class ActivityController:
61
63
 
62
64
  new_tiles = {
63
65
  zoom: sum(
64
- self._tile_visit_accessor.histories[zoom]["activity_id"]
66
+ self._tile_visit_accessor.tile_state["tile_history"][zoom][
67
+ "activity_id"
68
+ ]
65
69
  == activity["id"]
66
70
  )
67
- for zoom in [14, 17]
71
+ for zoom in sorted(self._config.explorer_zoom_levels)
68
72
  }
69
73
 
74
+ new_tiles_geojson = {}
75
+ for zoom in sorted(self._config.explorer_zoom_levels):
76
+ new_tiles = self._tile_visit_accessor.tile_state["tile_history"][zoom].loc[
77
+ self._tile_visit_accessor.tile_state["tile_history"][zoom][
78
+ "activity_id"
79
+ ]
80
+ == activity["id"]
81
+ ]
82
+ if len(new_tiles):
83
+ points = make_grid_points(
84
+ (
85
+ (row["tile_x"], row["tile_y"])
86
+ for index, row in new_tiles.iterrows()
87
+ ),
88
+ zoom,
89
+ )
90
+ new_tiles_geojson[zoom] = make_grid_file_geojson(points)
91
+
70
92
  result = {
71
93
  "activity": activity,
72
94
  "line_json": line_json,
@@ -79,6 +101,7 @@ class ActivityController:
79
101
  "date": activity["start"].date(),
80
102
  "time": activity["start"].time(),
81
103
  "new_tiles": new_tiles,
104
+ "new_tiles_geojson": new_tiles_geojson,
82
105
  }
83
106
  if (
84
107
  heart_zones := _extract_heart_rate_zones(
@@ -100,7 +123,9 @@ class ActivityController:
100
123
  time_series = privacy_zone.filter_time_series(time_series)
101
124
  if len(time_series) == 0:
102
125
  time_series = self._repository.get_time_series(id)
103
- return make_sharepic(activity, time_series)
126
+ return make_sharepic(
127
+ activity, time_series, self._config.sharepic_suppressed_fields
128
+ )
104
129
 
105
130
  def render_day(self, year: int, month: int, day: int) -> dict:
106
131
  meta = self._repository.meta
@@ -411,7 +436,11 @@ def pixels_in_bounds(bounds: PixelBounds) -> int:
411
436
  return (bounds.x_max - bounds.x_min) * (bounds.y_max - bounds.y_min)
412
437
 
413
438
 
414
- def make_sharepic(activity: ActivityMeta, time_series: pd.DataFrame) -> bytes:
439
+ def make_sharepic(
440
+ activity: ActivityMeta,
441
+ time_series: pd.DataFrame,
442
+ sharepic_suppressed_fields: list[str],
443
+ ) -> bytes:
415
444
  lat_lon_data = np.array([time_series["latitude"], time_series["longitude"]]).T
416
445
 
417
446
  geo_bounds = get_bounds(lat_lon_data)
@@ -448,19 +477,27 @@ def make_sharepic(activity: ActivityMeta, time_series: pd.DataFrame) -> bytes:
448
477
  draw.line(yx, fill="red", width=4)
449
478
 
450
479
  draw.rectangle([0, img.height - 70, img.width, img.height], fill=(0, 0, 0, 128))
451
- facts = [
452
- f"{activity['kind']}",
453
- f"{activity['start'].date()}",
454
- f"{activity['equipment']}",
455
- f"\n{activity['distance_km']:.1f} km",
456
- re.sub(r"^0 days ", "", f"{activity['elapsed_time']}"),
457
- ]
480
+
481
+ facts = {
482
+ "kind": f"{activity['kind']}",
483
+ "start": f"{activity['start'].date()}",
484
+ "equipment": f"{activity['equipment']}",
485
+ "distance_km": f"\n{activity['distance_km']:.1f} km",
486
+ "elapsed_time": re.sub(r"^0 days ", "", f"{activity['elapsed_time']}"),
487
+ }
488
+
458
489
  if activity.get("calories", 0) and not pd.isna(activity["calories"]):
459
- facts.append(f"{activity['calories']:.0f} kcal")
490
+ facts["calories"] = f"{activity['calories']:.0f} kcal"
460
491
  if activity.get("steps", 0) and not pd.isna(activity["steps"]):
461
- facts.append(f"{activity['steps']:.0f} steps")
492
+ facts["steps"] = f"{activity['steps']:.0f} steps"
493
+
494
+ facts = {
495
+ key: value
496
+ for key, value in facts.items()
497
+ if not key in sharepic_suppressed_fields
498
+ }
462
499
 
463
- draw.text((35, img.height - 70 + 10), " ".join(facts), font_size=20)
500
+ draw.text((35, img.height - 70 + 10), " ".join(facts.values()), font_size=20)
464
501
 
465
502
  # img_array = np.array(img) / 255
466
503
 
@@ -111,11 +111,7 @@
111
111
  </div>
112
112
 
113
113
  {% if heartrate_time_plot is defined %}
114
- <div class="row mb-3">
115
- <div class="col">
116
- <h2>Heart rate</h2>
117
- </div>
118
- </div>
114
+ <h2>Heart rate</h2>
119
115
 
120
116
  <div class="row mb-3">
121
117
  <div class="col-md-4">
@@ -133,13 +129,45 @@
133
129
  </div>
134
130
  {% endif %}
135
131
 
136
- <div class="row mb-3">
137
- <div class="col">
138
- <h2>Share picture</h2>
132
+ <h2>Share picture</h2>
133
+
134
+ <p><img src="{{ url_for('.sharepic', id=activity.id) }}" /></p>
135
+
136
+ <p>Not happy with the displayed data? <a href="{{ url_for('settings.sharepic') }}">Change share picture
137
+ settings</a>.</p>
138
+
139
+ {% if new_tiles_geojson %}
140
+ <h2>New explorer tiles</h2>
141
+ <p>With this activity you have explored new explorer tiles. The following maps show the new tiles on the respective zoom
142
+ levels.</p>
143
+ <script>
144
+ function add_map(id, geojson) {
145
+ let map = L.map(`map-${id}`, {
146
+ fullscreenControl: true
147
+ })
148
+ L.tileLayer('/tile/color/{z}/{x}/{y}.png', {
149
+ maxZoom: 19,
150
+ attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
151
+ }).addTo(map)
152
+
153
+ let geojson_layer = L.geoJSON(geojson).addTo(map)
154
+ map.fitBounds(geojson_layer.getBounds())
155
+ return map
156
+ }
157
+ </script>
139
158
 
140
- <img src="{{ url_for('.sharepic', id=activity.id) }}" />
159
+ <div class="row mb-3">
160
+ {% for zoom, geojson in new_tiles_geojson.items() %}
161
+ <div class="col-md-6">
162
+ <h3>Zoom {{ zoom }}</h3>
163
+ <div id="map-{{ zoom }}" style="height: 300px; width: 100%;"></div>
164
+ <script>
165
+ let map{{ zoom }} = add_map("{{ zoom }}", {{ geojson | safe }})
166
+ </script>
141
167
  </div>
168
+ {% endfor %}
142
169
  </div>
170
+ {% endif %}
143
171
 
144
172
  {% if similar_activites|length > 0 %}
145
173
  <div class="row mb-3">