geo-activity-playground 0.6.0__tar.gz → 0.8.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 (51) hide show
  1. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/PKG-INFO +4 -2
  2. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/__main__.py +3 -9
  3. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/activities.py +24 -15
  4. geo_activity_playground-0.8.0/geo_activity_playground/core/config.py +22 -0
  5. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/tiles.py +1 -1
  6. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/explorer/converters.py +10 -9
  7. geo_activity_playground-0.8.0/geo_activity_playground/explorer/grid_file.py +212 -0
  8. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/heatmap.py +1 -1
  9. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/importers/strava_api.py +2 -8
  10. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/activity_controller.py +4 -1
  11. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/app.py +7 -5
  12. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/explorer_controller.py +15 -6
  13. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/explorer.html.j2 +19 -7
  14. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/heatmap.html.j2 +1 -1
  15. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/page.html.j2 +4 -1
  16. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/pyproject.toml +3 -2
  17. geo_activity_playground-0.6.0/geo_activity_playground/explorer/grid_file.py +0 -160
  18. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/__init__.py +0 -0
  19. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/__init__.py +0 -0
  20. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/activity_parsers.py +0 -0
  21. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/coordinates.py +0 -0
  22. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/heatmap.py +0 -0
  23. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/plots.py +0 -0
  24. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/sources.py +0 -0
  25. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/tasks.py +0 -0
  26. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/core/test_tiles.py +0 -0
  27. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/explorer/__init__.py +0 -0
  28. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/explorer/video.py +0 -0
  29. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/importers/directory.py +0 -0
  30. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/calendar_controller.py +0 -0
  31. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/eddington_controller.py +0 -0
  32. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/entry_controller.py +0 -0
  33. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/equipment_controller.py +0 -0
  34. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/heatmap_controller.py +0 -0
  35. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/android-chrome-192x192.png +0 -0
  36. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
  37. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
  38. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/browserconfig.xml +0 -0
  39. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/favicon-16x16.png +0 -0
  40. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/favicon-32x32.png +0 -0
  41. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/favicon.ico +0 -0
  42. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/mstile-150x150.png +0 -0
  43. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -0
  44. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/static/site.webmanifest +0 -0
  45. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/activity.html.j2 +0 -0
  46. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/calendar-month.html.j2 +0 -0
  47. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/calendar.html.j2 +0 -0
  48. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/eddington.html.j2 +0 -0
  49. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/equipment.html.j2 +0 -0
  50. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/index.html.j2 +0 -0
  51. {geo_activity_playground-0.6.0 → geo_activity_playground-0.8.0}/geo_activity_playground/webui/templates/summary-statistics.html.j2 +0 -0
@@ -1,13 +1,14 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geo-activity-playground
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: Analysis of geo data activities like rides, runs or hikes.
5
5
  License: MIT
6
6
  Author: Martin Ueding
7
7
  Author-email: mu@martin-ueding.de
8
- Requires-Python: >=3.10,<3.12
8
+ Requires-Python: >=3.9,<3.12
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
11
12
  Classifier: Programming Language :: Python :: 3.10
12
13
  Classifier: Programming Language :: Python :: 3.11
13
14
  Requires-Dist: Pillow (>=9.2.0,<10.0.0)
@@ -27,6 +28,7 @@ Requires-Dist: requests (>=2.28.1,<3.0.0)
27
28
  Requires-Dist: scikit-learn (>=1.3.0,<2.0.0)
28
29
  Requires-Dist: scipy (>=1.8.1,<2.0.0)
29
30
  Requires-Dist: stravalib (>=1.3.3,<2.0.0)
31
+ Requires-Dist: tomli (>=2.0.1,<3.0.0) ; python_version < "3.11"
30
32
  Requires-Dist: tqdm (>=4.64.0,<5.0.0)
31
33
  Requires-Dist: vegafusion (>=1.4.3,<2.0.0)
32
34
  Requires-Dist: vegafusion-python-embed (>=1.4.3,<2.0.0)
@@ -1,16 +1,11 @@
1
1
  import argparse
2
2
  import os
3
3
  import pathlib
4
- import tomllib
5
4
 
6
5
  import coloredlogs
7
6
 
8
7
  from geo_activity_playground.core.activities import ActivityRepository
9
- from geo_activity_playground.core.sources import TimeSeriesSource
10
- from geo_activity_playground.explorer.grid_file import get_border_tiles
11
- from geo_activity_playground.explorer.grid_file import get_explored_tiles
12
- from geo_activity_playground.explorer.grid_file import make_grid_file_geojson
13
- from geo_activity_playground.explorer.grid_file import make_grid_file_gpx
8
+ from geo_activity_playground.core.config import get_config
14
9
  from geo_activity_playground.explorer.video import explorer_video_main
15
10
  from geo_activity_playground.heatmap import generate_heatmaps_per_cluster
16
11
  from geo_activity_playground.importers.directory import import_from_directory
@@ -78,11 +73,10 @@ def main() -> None:
78
73
 
79
74
  def make_activity_repository(basedir: pathlib.Path) -> ActivityRepository:
80
75
  os.chdir(basedir)
76
+ config = get_config()
81
77
  if pathlib.Path("Activities").exists():
82
78
  import_from_directory()
83
- elif pathlib.Path("config.toml").exists():
84
- with open("config.toml", "rb") as f:
85
- config = tomllib.load(f)
79
+ elif config:
86
80
  if "strava" in config:
87
81
  import_from_strava_api()
88
82
  return ActivityRepository()
@@ -2,16 +2,15 @@ import dataclasses
2
2
  import datetime
3
3
  import functools
4
4
  import logging
5
- import pathlib
6
- import tomllib
7
5
  from typing import Iterator
8
6
  from typing import Optional
9
7
 
10
8
  import geojson
11
9
  import matplotlib
12
- import numpy as np
13
10
  import pandas as pd
14
11
 
12
+ from geo_activity_playground.core.config import get_config
13
+
15
14
 
16
15
  logger = logging.getLogger(__name__)
17
16
 
@@ -105,25 +104,35 @@ def make_geojson_color_line(time_series: pd.DataFrame) -> str:
105
104
  def extract_heart_rate_zones(time_series: pd.DataFrame) -> Optional[pd.DataFrame]:
106
105
  if "heartrate" not in time_series:
107
106
  return None
108
- config_path = pathlib.Path("config.toml")
109
- if not config_path.exists():
110
- logger.warning("Missing a config, cannot extract heart rate zones.")
111
- return None
112
- with open(config_path, "rb") as f:
113
- config = tomllib.load(f)
114
-
107
+ config = get_config()
115
108
  try:
116
- birthyear = config["heart"]["birthyear"]
109
+ heart_config = config["heart"]
117
110
  except KeyError:
118
111
  logger.warning(
119
- "Missing config entry `heart.birthyear`, cannot determine heart rate zones."
112
+ "Missing config entry `heart`, cannot determine heart rate zones."
113
+ )
114
+ return None
115
+
116
+ birthyear = heart_config.get("birthyear", None)
117
+ maximum = heart_config.get("maximum", None)
118
+ resting = heart_config.get("resting", None)
119
+
120
+ if not maximum and birthyear:
121
+ age = time_series["time"].iloc[0].year - birthyear
122
+ maximum = 220 - age
123
+ if not resting:
124
+ resting = 0
125
+ if not maximum:
126
+ logger.warning(
127
+ "Missing config entry `heart.maximum` or `heart.birthyear`, cannot determine heart rate zones."
120
128
  )
121
129
  return None
122
130
 
123
- age = time_series["time"].iloc[0].year - birthyear
124
- max_rate = 220 - age
125
- zones: pd.Series = time_series["heartrate"] * 10 // max_rate - 4
131
+ zones: pd.Series = (time_series["heartrate"] - resting) * 10 // (
132
+ maximum - resting
133
+ ) - 4
126
134
  zones.loc[zones < 0] = 0
135
+ zones.loc[zones > 5] = 5
127
136
  df = pd.DataFrame({"heartzone": zones, "step": time_series["time"].diff()}).dropna()
128
137
  duration_per_zone = df.groupby("heartzone").sum()["step"].dt.total_seconds() / 60
129
138
  duration_per_zone.name = "minutes"
@@ -0,0 +1,22 @@
1
+ import functools
2
+ import logging
3
+ import pathlib
4
+
5
+
6
+ try:
7
+ import tomllib
8
+ except ModuleNotFoundError:
9
+ import tomli as tomllib
10
+
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @functools.cache
16
+ def get_config() -> dict:
17
+ config_path = pathlib.Path("config.toml")
18
+ if not config_path.exists():
19
+ logger.warning("Missing a config, some features might be missing.")
20
+ return {}
21
+ with open(config_path, "rb") as f:
22
+ return tomllib.load(f)
@@ -11,7 +11,7 @@ from PIL import Image
11
11
  logger = logging.getLogger(__name__)
12
12
 
13
13
 
14
- def compute_tile(lat: float, lon: float, zoom: int = 14) -> tuple[int, int]:
14
+ def compute_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
15
15
  x = np.radians(lon)
16
16
  y = np.arcsinh(np.tan(np.radians(lat)))
17
17
  x = (1 + x / np.pi) / 2
@@ -19,25 +19,26 @@ def explorer_per_activity_cache_dir() -> pathlib.Path:
19
19
  return path
20
20
 
21
21
 
22
- def get_first_tiles(id, repository: ActivityRepository) -> pd.DataFrame:
23
- target_path = explorer_per_activity_cache_dir() / f"{id}.parquet"
22
+ def get_first_tiles(id, repository: ActivityRepository, zoom: int) -> pd.DataFrame:
23
+ target_path = explorer_per_activity_cache_dir() / str(zoom) / f"{id}.parquet"
24
24
  if target_path.exists():
25
25
  return pd.read_parquet(target_path)
26
26
  else:
27
27
  logger.info(f"Extracting tiles from activity {id} …")
28
28
  time_series = repository.get_time_series(id)
29
- tiles = tiles_from_points(time_series)
29
+ tiles = tiles_from_points(time_series, zoom)
30
30
  first_tiles = first_time_per_tile(tiles)
31
+ target_path.parent.mkdir(exist_ok=True, parents=True)
31
32
  first_tiles.to_parquet(target_path)
32
33
  return first_tiles
33
34
 
34
35
 
35
- def tiles_from_points(points: pd.DataFrame) -> pd.DataFrame:
36
+ def tiles_from_points(points: pd.DataFrame, zoom: int) -> pd.DataFrame:
36
37
  assert pd.api.types.is_dtype_equal(points["time"].dtype, "datetime64[ns, UTC]")
37
38
  new_rows = []
38
39
  for index, row in points.iterrows():
39
40
  if "latitude" in row.keys() and "longitude" in row.keys():
40
- tile = compute_tile(row["latitude"], row["longitude"])
41
+ tile = compute_tile(row["latitude"], row["longitude"], zoom)
41
42
  new_rows.append((row["time"],) + tile)
42
43
  return pd.DataFrame(new_rows, columns=["time", "tile_x", "tile_y"])
43
44
 
@@ -48,17 +49,17 @@ def first_time_per_tile(tiles: pd.DataFrame) -> pd.DataFrame:
48
49
 
49
50
 
50
51
  @functools.cache
51
- def get_tile_history(repository: ActivityRepository) -> pd.DataFrame:
52
+ def get_tile_history(repository: ActivityRepository, zoom: int) -> pd.DataFrame:
52
53
  logger.info("Building explorer tile history from all activities …")
53
54
 
54
- cache_file = pathlib.Path("Cache/first_time_per_tile.parquet")
55
+ cache_file = pathlib.Path(f"Cache/first_time_per_tile_{zoom}.parquet")
55
56
  if cache_file.exists():
56
57
  tiles = pd.read_parquet(cache_file)
57
58
  else:
58
59
  tiles = pd.DataFrame()
59
60
 
60
61
  with work_tracker(
61
- pathlib.Path("Cache/task_first_time_per_tile.json")
62
+ pathlib.Path(f"Cache/task_first_time_per_tile_{zoom}.json")
62
63
  ) as parsed_activities:
63
64
  for activity in repository.iter_activities(new_to_old=False):
64
65
  if activity.id in parsed_activities:
@@ -66,7 +67,7 @@ def get_tile_history(repository: ActivityRepository) -> pd.DataFrame:
66
67
  parsed_activities.add(activity.id)
67
68
 
68
69
  logger.info(f"Activity {activity.id} wasn't parsed yet, reading them …")
69
- shard = get_first_tiles(activity.id, repository)
70
+ shard = get_first_tiles(activity.id, repository, zoom)
70
71
  shard["activity_id"] = activity.id
71
72
  if not len(shard):
72
73
  continue
@@ -0,0 +1,212 @@
1
+ import logging
2
+ import pathlib
3
+ from typing import Iterator
4
+
5
+ import geojson
6
+ import gpxpy
7
+ import numpy as np
8
+ import pandas as pd
9
+ import scipy.ndimage
10
+ import sklearn.cluster
11
+
12
+ from geo_activity_playground.core.activities import ActivityRepository
13
+ from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
14
+ from geo_activity_playground.explorer.converters import get_tile_history
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def get_three_color_tiles(
21
+ tiles: pd.DataFrame, repository: ActivityRepository, zoom: int
22
+ ) -> str:
23
+ # Create array with visited tiles.
24
+ a = np.zeros((2**zoom, 2**zoom), dtype=np.int8)
25
+ a[tiles["tile_x"], tiles["tile_y"]] = 1
26
+
27
+ tile_dict = {
28
+ elem: {"cluster": False, "square": False}
29
+ for elem in zip(tiles["tile_x"], tiles["tile_y"])
30
+ }
31
+
32
+ for x, y in tile_dict.keys():
33
+ if (
34
+ (x + 1, y) in tile_dict
35
+ and (x - 1, y) in tile_dict
36
+ and (x, y + 1) in tile_dict
37
+ and (x, y - 1) in tile_dict
38
+ ):
39
+ tile_dict[(x, y)]["cluster"] = True
40
+
41
+ # Compute biggest square.
42
+ square_size = 1
43
+ biggest = None
44
+ for x, y in sorted(tile_dict):
45
+ while True:
46
+ for i in range(square_size):
47
+ for j in range(square_size):
48
+ if (x + i, y + j) not in tile_dict:
49
+ break
50
+ else:
51
+ continue
52
+ break
53
+ else:
54
+ biggest = (x, y, square_size)
55
+ square_size += 1
56
+ continue
57
+ break
58
+
59
+ if biggest is not None:
60
+ square_x, square_y, square_size = biggest
61
+ for x in range(square_x, square_x + square_size):
62
+ for y in range(square_y, square_y + square_size):
63
+ tile_dict[(x, y)]["square"] = True
64
+
65
+ for index, row in tiles.iterrows():
66
+ tile_dict[(row["tile_x"], row["tile_y"])].update(
67
+ {
68
+ "first_visit": row["time"].date().isoformat(),
69
+ "activity_id": row["activity_id"],
70
+ "activity_name": repository.get_activity_by_id(row["activity_id"]).name,
71
+ "color": map_color(tile_dict[(row["tile_x"], row["tile_y"])]),
72
+ }
73
+ )
74
+
75
+ num_cluster_tiles = sum(value["cluster"] for value in tile_dict.values())
76
+
77
+ cluster_tiles = np.array(
78
+ [tile for tile, value in tile_dict.items() if value["cluster"]]
79
+ )
80
+
81
+ logger.info("Run DBSCAN cluster finding algorithm …")
82
+ dbscan = sklearn.cluster.DBSCAN(eps=1.1, min_samples=1)
83
+ labels = dbscan.fit_predict(cluster_tiles)
84
+ label_counts = dict(zip(*np.unique(labels, return_counts=True)))
85
+ max_cluster_size = max(
86
+ count for label, count in label_counts.items() if label != -1
87
+ )
88
+ for xy, label in zip(cluster_tiles, labels):
89
+ tile_dict[tuple(xy)]["cluster_id"] = int(label)
90
+ tile_dict[tuple(xy)]["this_cluster_size"] = int(label_counts[label])
91
+
92
+ # Find non-zero tiles.
93
+ result = {
94
+ "explored_geojson": geojson.dumps(
95
+ geojson.FeatureCollection(
96
+ features=[
97
+ make_explorer_tile(
98
+ x,
99
+ y,
100
+ tile_dict[(x, y)],
101
+ zoom,
102
+ )
103
+ for (x, y), v in tile_dict.items()
104
+ ]
105
+ )
106
+ ),
107
+ "max_cluster_size": max_cluster_size,
108
+ "num_cluster_tiles": num_cluster_tiles,
109
+ "num_tiles": len(tile_dict),
110
+ "square_size": square_size,
111
+ }
112
+ return result
113
+
114
+
115
+ def map_color(tile_meta: dict) -> str:
116
+ if tile_meta["square"]:
117
+ return "blue"
118
+ elif tile_meta["cluster"]:
119
+ return "green"
120
+ else:
121
+ return "red"
122
+
123
+
124
+ def get_border_tiles(tiles: pd.DataFrame, zoom: int) -> list[list[list[float]]]:
125
+ tile_set = set(zip(tiles["tile_x"], tiles["tile_y"]))
126
+ border_tiles = set()
127
+ for x, y in tile_set:
128
+ for neighbor in [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]:
129
+ if neighbor not in tile_set:
130
+ border_tiles.add(neighbor)
131
+ return make_grid_points(border_tiles, zoom)
132
+
133
+
134
+ def get_explored_tiles(tiles: pd.DataFrame, zoom: int) -> list[list[list[float]]]:
135
+ return make_grid_points(zip(tiles["tile_x"], tiles["tile_y"]), zoom)
136
+
137
+
138
+ def make_explorer_tile(
139
+ tile_x: int, tile_y: int, properties: dict, zoom: int
140
+ ) -> geojson.Feature:
141
+ corners = [
142
+ get_tile_upper_left_lat_lon(*args)
143
+ for args in [
144
+ (tile_x, tile_y, zoom),
145
+ (tile_x + 1, tile_y, zoom),
146
+ (tile_x + 1, tile_y + 1, zoom),
147
+ (tile_x, tile_y + 1, zoom),
148
+ (tile_x, tile_y, zoom),
149
+ ]
150
+ ]
151
+ return geojson.Feature(
152
+ geometry=geojson.Polygon([[(coord[1], coord[0]) for coord in corners]]),
153
+ properties=properties,
154
+ )
155
+
156
+
157
+ def make_grid_points(
158
+ tile_iterator: Iterator[tuple[int, int]], zoom: int
159
+ ) -> list[list[list[float]]]:
160
+ result = []
161
+ for tile_x, tile_y in tile_iterator:
162
+ tile = [
163
+ get_tile_upper_left_lat_lon(tile_x, tile_y, zoom),
164
+ get_tile_upper_left_lat_lon(tile_x + 1, tile_y, zoom),
165
+ get_tile_upper_left_lat_lon(tile_x + 1, tile_y + 1, zoom),
166
+ get_tile_upper_left_lat_lon(tile_x, tile_y + 1, zoom),
167
+ get_tile_upper_left_lat_lon(tile_x, tile_y, zoom),
168
+ ]
169
+ result.append(tile)
170
+ return result
171
+
172
+
173
+ def make_grid_file_gpx(grid_points: list[list[list[float]]], stem: str) -> None:
174
+ gpx = gpxpy.gpx.GPX()
175
+ gpx_track = gpxpy.gpx.GPXTrack()
176
+ gpx.tracks.append(gpx_track)
177
+
178
+ for points in grid_points:
179
+ gpx_segment = gpxpy.gpx.GPXTrackSegment()
180
+ gpx_track.segments.append(gpx_segment)
181
+ for point in points:
182
+ gpx_segment.points.append(gpxpy.gpx.GPXTrackPoint(*point))
183
+
184
+ out_path = pathlib.Path("Download") / f"{stem}.gpx"
185
+ out_path.parent.mkdir(exist_ok=True, parents=True)
186
+
187
+ with open(out_path, "w") as f:
188
+ f.write(gpx.to_xml())
189
+
190
+
191
+ def make_grid_file_geojson(grid_points: list[list[list[float]]], stem: str) -> str:
192
+ fc = geojson.FeatureCollection(
193
+ [
194
+ geojson.Feature(
195
+ geometry=geojson.Polygon([[[lon, lat] for lat, lon in points]])
196
+ )
197
+ for points in grid_points
198
+ ]
199
+ )
200
+ result = geojson.dumps(fc, sort_keys=True, indent=4, ensure_ascii=False)
201
+ out_path = pathlib.Path("Download") / f"{stem}.geojson"
202
+ out_path.parent.mkdir(exist_ok=True, parents=True)
203
+ with open(out_path, "w") as f:
204
+ f.write(result)
205
+ return result
206
+
207
+
208
+ def get_explored_geojson(repository: ActivityRepository, zoom: int) -> str:
209
+ tiles = get_tile_history(repository, zoom)
210
+ return make_grid_file_geojson(
211
+ make_grid_points(zip(tiles["tile_x"], tiles["tile_y"]))
212
+ )
@@ -211,7 +211,7 @@ def generate_heatmaps_per_cluster(repository: ActivityRepository) -> None:
211
211
  del arrays
212
212
 
213
213
  logger.info("Compute tiles for each point …")
214
- tiles = [compute_tile(lat, lon) for lat, lon in latlon]
214
+ tiles = [compute_tile(lat, lon, 14) for lat, lon in latlon]
215
215
 
216
216
  unique_tiles = set(tiles)
217
217
  unique_tiles_array = np.array(list(unique_tiles))
@@ -4,22 +4,16 @@ import json
4
4
  import logging
5
5
  import pathlib
6
6
  import pickle
7
- import tomllib
8
7
  from typing import Any
9
8
 
10
9
  import pandas as pd
11
10
  from stravalib import Client
12
11
  from stravalib.exc import RateLimitExceeded
13
12
 
14
-
15
- logger = logging.getLogger(__name__)
13
+ from geo_activity_playground.core.config import get_config
16
14
 
17
15
 
18
- @functools.cache
19
- def get_config() -> dict:
20
- config_path = pathlib.Path("config.toml")
21
- with open(config_path, "rb") as f:
22
- return tomllib.load(f)
16
+ logger = logging.getLogger(__name__)
23
17
 
24
18
 
25
19
  def get_state(path: pathlib.Path) -> Any:
@@ -19,6 +19,7 @@ class ActivityController:
19
19
  activity = self._repository.get_activity_by_id(id)
20
20
 
21
21
  time_series = self._repository.get_time_series(id)
22
+ time_series["distance/km"] = time_series["distance"] / 1000
22
23
  line_json = make_geojson_from_time_series(time_series)
23
24
 
24
25
  result = {
@@ -40,7 +41,9 @@ def distance_time_plot(time_series: pd.DataFrame) -> str:
40
41
  return (
41
42
  alt.Chart(time_series, title="Distance")
42
43
  .mark_line()
43
- .encode(alt.X("time", title="Time"), alt.Y("distance", title="Distance / km"))
44
+ .encode(
45
+ alt.X("time", title="Time"), alt.Y("distance/km", title="Distance / km")
46
+ )
44
47
  .interactive()
45
48
  .to_json(format="vega")
46
49
  )
@@ -33,19 +33,21 @@ def webui_main(repository: ActivityRepository) -> None:
33
33
  return render_template("index.html.j2", **entry_controller.render())
34
34
 
35
35
  @app.route("/activity/<id>")
36
- def activity(id: int):
36
+ def activity(id: str):
37
37
  return render_template(
38
38
  "activity.html.j2", **activity_controller.render_activity(int(id))
39
39
  )
40
40
 
41
41
  @app.route("/activity/<id>/track.json")
42
- def activity_track(id: int):
42
+ def activity_track(id: str):
43
43
  plot = activity_track_plot(repository.get_time_series(int(id)))
44
44
  return plot
45
45
 
46
- @app.route("/explorer")
47
- def explorer():
48
- return render_template("explorer.html.j2", **explorer_controller.render())
46
+ @app.route("/explorer/<zoom>")
47
+ def explorer(zoom: str):
48
+ return render_template(
49
+ "explorer.html.j2", **explorer_controller.render(int(zoom))
50
+ )
49
51
 
50
52
  @app.route("/summary-statistics")
51
53
  def summary_statistics():
@@ -1,6 +1,7 @@
1
1
  import functools
2
2
 
3
3
  from geo_activity_playground.core.activities import ActivityRepository
4
+ from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
4
5
  from geo_activity_playground.explorer.converters import get_tile_history
5
6
  from geo_activity_playground.explorer.grid_file import get_border_tiles
6
7
  from geo_activity_playground.explorer.grid_file import get_explored_geojson
@@ -15,20 +16,28 @@ class ExplorerController:
15
16
  self._repository = repository
16
17
 
17
18
  @functools.cache
18
- def render(self) -> dict:
19
- tiles = get_tile_history(self._repository)
19
+ def render(self, zoom: int) -> dict:
20
+ tiles = get_tile_history(self._repository, zoom)
21
+ medians = tiles.median()
22
+ median_lat, median_lon = get_tile_upper_left_lat_lon(
23
+ medians["tile_x"], medians["tile_y"], zoom
24
+ )
20
25
 
21
- explored_geojson = get_three_color_tiles(tiles, self._repository)
26
+ explored = get_three_color_tiles(tiles, self._repository, zoom)
22
27
 
23
- points = get_border_tiles(tiles)
28
+ points = get_border_tiles(tiles, zoom)
24
29
  missing_tiles_geojson = make_grid_file_geojson(points, "missing_tiles")
25
30
  make_grid_file_gpx(points, "missing_tiles")
26
31
 
27
- points = get_explored_tiles(tiles)
32
+ points = get_explored_tiles(tiles, zoom)
28
33
  explored_tiles_geojson = make_grid_file_geojson(points, "explored")
29
34
  make_grid_file_gpx(points, "explored")
30
35
 
31
36
  return {
32
- "explored_geojson": explored_geojson,
37
+ "center": {
38
+ "latitude": median_lat,
39
+ "longitude": median_lon,
40
+ },
41
+ "explored": explored,
33
42
  "missing_tiles_geojson": missing_tiles_geojson,
34
43
  }
@@ -4,6 +4,11 @@
4
4
  <div class="row mb-3">
5
5
  <div class="col">
6
6
  <h1>Explorer Tiles</h1>
7
+ <p>You have {{ explored.num_tiles }} explored tiles. There are {{ explored.num_cluster_tiles }} cluster tiles in
8
+ total. Your largest cluster consists of {{ explored.max_cluster_size }} tiles. Your largest square has size
9
+ {{
10
+ explored.square_size }}².
11
+ </p>
7
12
  </div>
8
13
  </div>
9
14
 
@@ -22,14 +27,21 @@
22
27
  <script>
23
28
  function onEachFeature(feature, layer) {
24
29
  if (feature.properties && feature.properties.first_visit) {
25
- layer.bindPopup(`First visited on ${feature.properties.first_visit} as part of <a href=/activity/${feature.properties.activity_id}>${feature.properties.activity_name}</a>.`)
30
+ let lines = [`First visit: ${feature.properties.first_visit}`,
31
+ `First activity: <a href=/activity/${feature.properties.activity_id}>${feature.properties.activity_name}</a>`]
32
+ if (feature.properties.this_cluster_size) {
33
+ lines.push(`This cluster size: ${feature.properties.this_cluster_size}`)
34
+ }
35
+ layer.bindPopup(lines.join('<br />'))
26
36
  }
27
37
  }
28
38
 
29
- let explorer_geojson = {{ explored_geojson| safe}}
39
+ let explorer_geojson = {{ explored.explored_geojson| safe}}
30
40
  let map = L.map('explorer-map', {
31
- fullscreenControl: true
32
- });
41
+ fullscreenControl: true,
42
+ center: [{{ center.latitude }}, {{ center.longitude }}],
43
+ zoom: 10
44
+ })
33
45
  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
34
46
  maxZoom: 19,
35
47
  attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
@@ -38,7 +50,6 @@
38
50
  style: function (feature) { return { color: feature.properties.color, fillColor: feature.properties.color, weight: 1 } },
39
51
  onEachFeature: onEachFeature
40
52
  }).addTo(map)
41
- map.fitBounds(explorer_layer.getBounds())
42
53
  </script>
43
54
  </div>
44
55
  </div>
@@ -59,14 +70,15 @@
59
70
  <script>
60
71
  let missing_geojson = {{ missing_tiles_geojson| safe}}
61
72
  let missing_map = L.map('missing-map', {
62
- fullscreenControl: true
73
+ fullscreenControl: true,
74
+ center: [{{ center.latitude }}, {{ center.longitude }}],
75
+ zoom: 10
63
76
  });
64
77
  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
65
78
  maxZoom: 19,
66
79
  attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
67
80
  }).addTo(missing_map)
68
81
  let missing_layer = L.geoJSON(missing_geojson).addTo(missing_map)
69
- missing_map.fitBounds(missing_layer.getBounds())
70
82
  </script>
71
83
  </div>
72
84
  </div>
@@ -15,7 +15,7 @@
15
15
  let map = L.map('heatmap', {
16
16
  fullscreenControl: true,
17
17
  center: [{{ center.latitude }}, {{ center.longitude }}],
18
- zoom: 12
18
+ zoom: 12
19
19
  });
20
20
  L.tileLayer('/heatmap/tile/{z}/{x}/{y}.png', {
21
21
  maxZoom: 19,
@@ -62,7 +62,10 @@
62
62
  <a class="nav-link active" aria-current="page" href="/calendar">Calendar</a>
63
63
  </li>
64
64
  <li class="nav-item">
65
- <a class="nav-link active" aria-current="page" href="/explorer">Explorer</a>
65
+ <a class="nav-link active" aria-current="page" href="/explorer/14">Explorer</a>
66
+ </li>
67
+ <li class="nav-item">
68
+ <a class="nav-link active" aria-current="page" href="/explorer/17">Squadratinhos</a>
66
69
  </li>
67
70
  <li class="nav-item">
68
71
  <a class="nav-link active" aria-current="page" href="/heatmap">Heatmap</a>
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "geo-activity-playground"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "Analysis of geo data activities like rides, runs or hikes."
5
5
  authors = ["Martin Ueding <mu@martin-ueding.de>"]
6
6
  license = "MIT"
@@ -9,7 +9,7 @@ license = "MIT"
9
9
  geo-activity-playground = "geo_activity_playground.__main__:main"
10
10
 
11
11
  [tool.poetry.dependencies]
12
- python = "^3.10,<3.12"
12
+ python = "^3.9,<3.12"
13
13
 
14
14
  altair = "^5.1.2"
15
15
  appdirs = "^1.4.4"
@@ -28,6 +28,7 @@ requests = "^2.28.1"
28
28
  scikit-learn = "^1.3.0"
29
29
  scipy = "^1.8.1"
30
30
  stravalib = "^1.3.3"
31
+ tomli = { version = "^2.0.1", python = "<3.11" }
31
32
  tqdm = "^4.64.0"
32
33
  vegafusion = "^1.4.3"
33
34
  vegafusion-python-embed = "^1.4.3"
@@ -1,160 +0,0 @@
1
- import pathlib
2
- from typing import Iterator
3
-
4
- import geojson
5
- import gpxpy
6
- import numpy as np
7
- import pandas as pd
8
- import scipy.ndimage
9
-
10
- from geo_activity_playground.core.activities import ActivityRepository
11
- from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
12
- from geo_activity_playground.explorer.converters import get_tile_history
13
-
14
-
15
- def get_three_color_tiles(tiles: pd.DataFrame, repository: ActivityRepository) -> str:
16
- # Create array with visited tiles.
17
- a = np.zeros((2**14, 2**14), dtype=np.int8)
18
- a[tiles["tile_x"], tiles["tile_y"]] = 1
19
-
20
- # Get cluster tiles via erosion.
21
- cluster = scipy.ndimage.binary_erosion(a)
22
- a[cluster] = 2
23
-
24
- # Compute biggest square.
25
- square_size = 1
26
- biggest = None
27
- tile_set = {elem for elem in zip(tiles["tile_x"], tiles["tile_y"])}
28
- for x, y in sorted(tile_set):
29
- while True:
30
- for i in range(square_size):
31
- for j in range(square_size):
32
- if (x + i, y + j) not in tile_set:
33
- break
34
- else:
35
- continue
36
- break
37
- else:
38
- biggest = (x, y, square_size)
39
- square_size += 1
40
- continue
41
- break
42
-
43
- if biggest is not None:
44
- square_x, square_y, square_size = biggest
45
- a[square_x : square_x + square_size, square_y : square_y + square_size] = 3
46
-
47
- tile_metadata = {
48
- (row["tile_x"], row["tile_y"]): {
49
- "first_visit": row["time"].date().isoformat(),
50
- "activity_id": row["activity_id"],
51
- "activity_name": repository.get_activity_by_id(row["activity_id"]).name,
52
- }
53
- for index, row in tiles.iterrows()
54
- }
55
-
56
- # Find non-zero tiles.
57
- border_x, border_y = np.where(a)
58
- return geojson.dumps(
59
- geojson.FeatureCollection(
60
- features=[
61
- make_explorer_tile(
62
- x,
63
- y,
64
- {
65
- "color": {1: "red", 2: "green", 3: "blue"}[a[x, y]],
66
- **tile_metadata[(x, y)],
67
- },
68
- )
69
- for x, y in zip(border_x, border_y)
70
- ]
71
- )
72
- )
73
-
74
-
75
- def get_border_tiles(tiles: pd.DataFrame) -> list[list[list[float]]]:
76
- a = np.zeros((2**14, 2**14), dtype=np.int8)
77
- a[tiles["tile_x"], tiles["tile_y"]] = 1
78
- dilated = scipy.ndimage.binary_dilation(a, iterations=2)
79
- border = dilated - a
80
- border_x, border_y = np.where(border)
81
- return make_grid_points(zip(border_x, border_y))
82
-
83
-
84
- def get_explored_tiles(tiles: pd.DataFrame) -> list[list[list[float]]]:
85
- return make_grid_points(zip(tiles["tile_x"], tiles["tile_y"]))
86
-
87
-
88
- def make_explorer_tile(tile_x: int, tile_y: int, properties: dict) -> geojson.Feature:
89
- corners = [
90
- get_tile_upper_left_lat_lon(*args)
91
- for args in [
92
- (tile_x, tile_y, 14),
93
- (tile_x + 1, tile_y, 14),
94
- (tile_x + 1, tile_y + 1, 14),
95
- (tile_x, tile_y + 1, 14),
96
- (tile_x, tile_y, 14),
97
- ]
98
- ]
99
- return geojson.Feature(
100
- geometry=geojson.Polygon([[(coord[1], coord[0]) for coord in corners]]),
101
- properties=properties,
102
- )
103
-
104
-
105
- def make_grid_points(
106
- tile_iterator: Iterator[tuple[int, int]]
107
- ) -> list[list[list[float]]]:
108
- result = []
109
- for tile_x, tile_y in tile_iterator:
110
- tile = [
111
- get_tile_upper_left_lat_lon(tile_x, tile_y, 14),
112
- get_tile_upper_left_lat_lon(tile_x + 1, tile_y, 14),
113
- get_tile_upper_left_lat_lon(tile_x + 1, tile_y + 1, 14),
114
- get_tile_upper_left_lat_lon(tile_x, tile_y + 1, 14),
115
- get_tile_upper_left_lat_lon(tile_x, tile_y, 14),
116
- ]
117
- result.append(tile)
118
- return result
119
-
120
-
121
- def make_grid_file_gpx(grid_points: list[list[list[float]]], stem: str) -> None:
122
- gpx = gpxpy.gpx.GPX()
123
- gpx_track = gpxpy.gpx.GPXTrack()
124
- gpx.tracks.append(gpx_track)
125
-
126
- for points in grid_points:
127
- gpx_segment = gpxpy.gpx.GPXTrackSegment()
128
- gpx_track.segments.append(gpx_segment)
129
- for point in points:
130
- gpx_segment.points.append(gpxpy.gpx.GPXTrackPoint(*point))
131
-
132
- out_path = pathlib.Path("Download") / f"{stem}.gpx"
133
- out_path.parent.mkdir(exist_ok=True, parents=True)
134
-
135
- with open(out_path, "w") as f:
136
- f.write(gpx.to_xml())
137
-
138
-
139
- def make_grid_file_geojson(grid_points: list[list[list[float]]], stem: str) -> str:
140
- fc = geojson.FeatureCollection(
141
- [
142
- geojson.Feature(
143
- geometry=geojson.Polygon([[[lon, lat] for lat, lon in points]])
144
- )
145
- for points in grid_points
146
- ]
147
- )
148
- result = geojson.dumps(fc, sort_keys=True, indent=4, ensure_ascii=False)
149
- out_path = pathlib.Path("Download") / f"{stem}.geojson"
150
- out_path.parent.mkdir(exist_ok=True, parents=True)
151
- with open(out_path, "w") as f:
152
- f.write(result)
153
- return result
154
-
155
-
156
- def get_explored_geojson(repository: ActivityRepository) -> str:
157
- tiles = get_tile_history(repository)
158
- return make_grid_file_geojson(
159
- make_grid_points(zip(tiles["tile_x"], tiles["tile_y"]))
160
- )