geo-activity-playground 0.23.0__py3-none-any.whl → 0.24.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 (77) hide show
  1. geo_activity_playground/__main__.py +1 -1
  2. geo_activity_playground/core/activities.py +18 -12
  3. geo_activity_playground/core/activity_parsers.py +8 -32
  4. geo_activity_playground/core/cache_migrations.py +24 -0
  5. geo_activity_playground/core/heatmap.py +21 -21
  6. geo_activity_playground/core/privacy_zones.py +16 -0
  7. geo_activity_playground/core/similarity.py +1 -1
  8. geo_activity_playground/core/test_time_conversion.py +37 -0
  9. geo_activity_playground/core/time_conversion.py +14 -0
  10. geo_activity_playground/explorer/tile_visits.py +44 -32
  11. geo_activity_playground/importers/__init__.py +0 -0
  12. geo_activity_playground/importers/directory.py +7 -2
  13. geo_activity_playground/importers/strava_api.py +8 -1
  14. geo_activity_playground/importers/strava_checkout.py +4 -3
  15. geo_activity_playground/webui/__init__.py +0 -0
  16. geo_activity_playground/webui/activity/__init__.py +0 -0
  17. geo_activity_playground/webui/activity/blueprint.py +58 -0
  18. geo_activity_playground/webui/{activity_controller.py → activity/controller.py} +128 -18
  19. geo_activity_playground/webui/{templates/activity-day.html.j2 → activity/templates/activity/day.html.j2} +14 -2
  20. geo_activity_playground/webui/{templates/activity-name.html.j2 → activity/templates/activity/name.html.j2} +1 -1
  21. geo_activity_playground/webui/{templates/activity.html.j2 → activity/templates/activity/show.html.j2} +9 -4
  22. geo_activity_playground/webui/app.py +54 -283
  23. geo_activity_playground/webui/calendar/__init__.py +0 -0
  24. geo_activity_playground/webui/calendar/blueprint.py +26 -0
  25. geo_activity_playground/webui/{calendar_controller.py → calendar/controller.py} +5 -5
  26. geo_activity_playground/webui/{templates/calendar.html.j2 → calendar/templates/calendar/index.html.j2} +3 -2
  27. geo_activity_playground/webui/{templates/calendar-month.html.j2 → calendar/templates/calendar/month.html.j2} +2 -2
  28. geo_activity_playground/webui/eddington/__init__.py +0 -0
  29. geo_activity_playground/webui/eddington/blueprint.py +19 -0
  30. geo_activity_playground/webui/{eddington_controller.py → eddington/controller.py} +14 -6
  31. geo_activity_playground/webui/eddington/templates/eddington/index.html.j2 +56 -0
  32. geo_activity_playground/webui/entry_controller.py +1 -1
  33. geo_activity_playground/webui/equipment/__init__.py +0 -0
  34. geo_activity_playground/webui/equipment/blueprint.py +19 -0
  35. geo_activity_playground/webui/{equipment_controller.py → equipment/controller.py} +5 -3
  36. geo_activity_playground/webui/explorer/__init__.py +0 -0
  37. geo_activity_playground/webui/explorer/blueprint.py +54 -0
  38. geo_activity_playground/webui/{templates/explorer.html.j2 → explorer/templates/explorer/index.html.j2} +2 -2
  39. geo_activity_playground/webui/heatmap/__init__.py +0 -0
  40. geo_activity_playground/webui/heatmap/blueprint.py +41 -0
  41. geo_activity_playground/webui/{heatmap_controller.py → heatmap/heatmap_controller.py} +38 -11
  42. geo_activity_playground/webui/{templates/heatmap.html.j2 → heatmap/templates/heatmap/index.html.j2} +17 -2
  43. geo_activity_playground/webui/search_controller.py +1 -9
  44. geo_activity_playground/webui/square_planner/__init__.py +0 -0
  45. geo_activity_playground/webui/square_planner/blueprint.py +38 -0
  46. geo_activity_playground/webui/summary/__init__.py +0 -0
  47. geo_activity_playground/webui/summary/blueprint.py +16 -0
  48. geo_activity_playground/webui/summary/controller.py +268 -0
  49. geo_activity_playground/webui/summary/templates/summary/index.html.j2 +135 -0
  50. geo_activity_playground/webui/templates/{index.html.j2 → home.html.j2} +1 -1
  51. geo_activity_playground/webui/templates/page.html.j2 +22 -19
  52. geo_activity_playground/webui/templates/search.html.j2 +1 -1
  53. geo_activity_playground/webui/tile/__init__.py +0 -0
  54. geo_activity_playground/webui/tile/blueprint.py +31 -0
  55. geo_activity_playground/webui/upload/__init__.py +0 -0
  56. geo_activity_playground/webui/upload/blueprint.py +28 -0
  57. geo_activity_playground/webui/{upload_controller.py → upload/controller.py} +1 -0
  58. geo_activity_playground/webui/{templates/upload.html.j2 → upload/templates/upload/index.html.j2} +1 -1
  59. {geo_activity_playground-0.23.0.dist-info → geo_activity_playground-0.24.1.dist-info}/METADATA +2 -1
  60. geo_activity_playground-0.24.1.dist-info/RECORD +95 -0
  61. geo_activity_playground/webui/config_controller.py +0 -12
  62. geo_activity_playground/webui/locations_controller.py +0 -28
  63. geo_activity_playground/webui/summary_controller.py +0 -60
  64. geo_activity_playground/webui/templates/config.html.j2 +0 -24
  65. geo_activity_playground/webui/templates/eddington.html.j2 +0 -18
  66. geo_activity_playground/webui/templates/locations.html.j2 +0 -38
  67. geo_activity_playground/webui/templates/summary.html.j2 +0 -21
  68. geo_activity_playground-0.23.0.dist-info/RECORD +0 -74
  69. /geo_activity_playground/webui/{templates/activity-lines.html.j2 → activity/templates/activity/lines.html.j2} +0 -0
  70. /geo_activity_playground/webui/{templates/equipment.html.j2 → equipment/templates/equipment/index.html.j2} +0 -0
  71. /geo_activity_playground/webui/{explorer_controller.py → explorer/controller.py} +0 -0
  72. /geo_activity_playground/webui/{square_planner_controller.py → square_planner/controller.py} +0 -0
  73. /geo_activity_playground/webui/{templates/square-planner.html.j2 → square_planner/templates/square_planner/index.html.j2} +0 -0
  74. /geo_activity_playground/webui/{tile_controller.py → tile/controller.py} +0 -0
  75. {geo_activity_playground-0.23.0.dist-info → geo_activity_playground-0.24.1.dist-info}/LICENSE +0 -0
  76. {geo_activity_playground-0.23.0.dist-info → geo_activity_playground-0.24.1.dist-info}/WHEEL +0 -0
  77. {geo_activity_playground-0.23.0.dist-info → geo_activity_playground-0.24.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,54 @@
1
+ from flask import Blueprint
2
+ from flask import render_template
3
+ from flask import Response
4
+
5
+ from ...core.activities import ActivityRepository
6
+ from ...explorer.tile_visits import TileVisitAccessor
7
+ from .controller import ExplorerController
8
+
9
+
10
+ def make_explorer_blueprint(
11
+ repository: ActivityRepository, tile_visit_accessor: TileVisitAccessor
12
+ ) -> Blueprint:
13
+ explorer_controller = ExplorerController(repository, tile_visit_accessor)
14
+ blueprint = Blueprint("explorer", __name__, template_folder="templates")
15
+
16
+ @blueprint.route("/<zoom>")
17
+ def map(zoom: str):
18
+ return render_template(
19
+ "explorer/index.html.j2", **explorer_controller.render(int(zoom))
20
+ )
21
+
22
+ @blueprint.route("/<zoom>/<north>/<east>/<south>/<west>/explored.<suffix>")
23
+ def download(zoom: str, north: str, east: str, south: str, west: str, suffix: str):
24
+ mimetypes = {"geojson": "application/json", "gpx": "application/xml"}
25
+ return Response(
26
+ explorer_controller.export_explored_tiles(
27
+ int(zoom),
28
+ float(north),
29
+ float(east),
30
+ float(south),
31
+ float(west),
32
+ suffix,
33
+ ),
34
+ mimetype=mimetypes[suffix],
35
+ headers={"Content-disposition": "attachment"},
36
+ )
37
+
38
+ @blueprint.route("/<zoom>/<north>/<east>/<south>/<west>/missing.<suffix>")
39
+ def missing(zoom: str, north: str, east: str, south: str, west: str, suffix: str):
40
+ mimetypes = {"geojson": "application/json", "gpx": "application/xml"}
41
+ return Response(
42
+ explorer_controller.export_missing_tiles(
43
+ int(zoom),
44
+ float(north),
45
+ float(east),
46
+ float(south),
47
+ float(west),
48
+ suffix,
49
+ ),
50
+ mimetype=mimetypes[suffix],
51
+ headers={"Content-disposition": "attachment"},
52
+ )
53
+
54
+ return blueprint
@@ -10,8 +10,8 @@
10
10
  explored.square_size }}².
11
11
  </p>
12
12
  <p>Open the <a
13
- href="/square-planner/{{ zoom }}/{{ explored.square_x }}/{{ explored.square_y }}/{{ explored.square_size }}">Square
14
- Planner</a> to plan the next extention.</p>
13
+ href="{{ url_for('square_planner.index', zoom=zoom, x=explored.square_x, y=explored.square_y, size=explored.square_size) }}">Square
14
+ Planner</a> to plan the next extension.</p>
15
15
  </div>
16
16
  </div>
17
17
 
File without changes
@@ -0,0 +1,41 @@
1
+ from flask import Blueprint
2
+ from flask import render_template
3
+ from flask import request
4
+ from flask import Response
5
+
6
+ from ...core.activities import ActivityRepository
7
+ from ...explorer.tile_visits import TileVisitAccessor
8
+ from .heatmap_controller import HeatmapController
9
+
10
+
11
+ def make_heatmap_blueprint(
12
+ repository: ActivityRepository, tile_visit_accessor: TileVisitAccessor
13
+ ) -> Blueprint:
14
+ heatmap_controller = HeatmapController(repository, tile_visit_accessor)
15
+ blueprint = Blueprint("heatmap", __name__, template_folder="templates")
16
+
17
+ @blueprint.route("/")
18
+ def index():
19
+ return render_template(
20
+ "heatmap/index.html.j2",
21
+ **heatmap_controller.render(request.args.getlist("kind"))
22
+ )
23
+
24
+ @blueprint.route("/tile/<z>/<x>/<y>/<kinds>.png")
25
+ def tile(x: str, y: str, z: str, kinds: str):
26
+ return Response(
27
+ heatmap_controller.render_tile(int(x), int(y), int(z), kinds.split(";")),
28
+ mimetype="image/png",
29
+ )
30
+
31
+ @blueprint.route("/download/<north>/<east>/<south>/<west>/<kinds>")
32
+ def download(north: str, east: str, south: str, west: str, kinds: str):
33
+ return Response(
34
+ heatmap_controller.download_heatmap(
35
+ float(north), float(east), float(south), float(west), kinds.split(";")
36
+ ),
37
+ mimetype="image/png",
38
+ headers={"Content-disposition": 'attachment; filename="heatmap.png"'},
39
+ )
40
+
41
+ return blueprint
@@ -11,11 +11,12 @@ from geo_activity_playground.core.activities import ActivityRepository
11
11
  from geo_activity_playground.core.heatmap import convert_to_grayscale
12
12
  from geo_activity_playground.core.heatmap import GeoBounds
13
13
  from geo_activity_playground.core.heatmap import get_sensible_zoom_level
14
+ from geo_activity_playground.core.heatmap import PixelBounds
14
15
  from geo_activity_playground.core.tasks import work_tracker
15
16
  from geo_activity_playground.core.tiles import get_tile
16
17
  from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
17
18
  from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
18
- from geo_activity_playground.webui.explorer_controller import (
19
+ from geo_activity_playground.webui.explorer.controller import (
19
20
  bounding_box_for_biggest_cluster,
20
21
  )
21
22
 
@@ -36,8 +37,9 @@ class HeatmapController:
36
37
  self.tile_histories = self._tile_visit_accessor.histories
37
38
  self.tile_evolution_states = self._tile_visit_accessor.states
38
39
  self.tile_visits = self._tile_visit_accessor.visits
40
+ self.activities_per_tile = self._tile_visit_accessor.activities_per_tile
39
41
 
40
- def render(self) -> dict:
42
+ def render(self, kinds: list[str] = []) -> dict:
41
43
  zoom = 14
42
44
  tiles = self.tile_histories[zoom]
43
45
  medians = tiles.median(skipna=True)
@@ -45,6 +47,12 @@ class HeatmapController:
45
47
  medians["tile_x"], medians["tile_y"], zoom
46
48
  )
47
49
  cluster_state = self.tile_evolution_states[zoom]
50
+
51
+ available_kinds = sorted(self._repository.meta["kind"].unique())
52
+
53
+ if not kinds:
54
+ kinds = available_kinds
55
+
48
56
  return {
49
57
  "center": {
50
58
  "latitude": median_lat,
@@ -56,18 +64,21 @@ class HeatmapController:
56
64
  if len(cluster_state.memberships) > 0
57
65
  else {}
58
66
  ),
59
- }
67
+ },
68
+ "kinds": kinds,
69
+ "available_kinds": available_kinds,
70
+ "kinds_str": ";".join(kinds),
60
71
  }
61
72
 
62
- def _render_tile_image(self, x: int, y: int, z: int) -> np.ndarray:
73
+ def _get_counts(self, x: int, y: int, z: int, kind: str) -> np.ndarray:
63
74
  tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
64
- tile_count_cache_path = pathlib.Path(f"Cache/Heatmap/{z}/{x}/{y}.npy")
75
+ tile_count_cache_path = pathlib.Path(f"Cache/Heatmap/{kind}/{z}/{x}/{y}.npy")
65
76
  if tile_count_cache_path.exists():
66
77
  tile_counts = np.load(tile_count_cache_path)
67
78
  else:
68
79
  tile_counts = np.zeros(tile_pixels, dtype=np.int32)
69
80
  tile_count_cache_path.parent.mkdir(parents=True, exist_ok=True)
70
- activity_ids = self.tile_visits[z].get((x, y), {}).get("activity_ids", set())
81
+ activity_ids = self.activities_per_tile[z].get((x, y), set())
71
82
  if activity_ids:
72
83
  with work_tracker(
73
84
  tile_count_cache_path.with_suffix(".json")
@@ -76,6 +87,9 @@ class HeatmapController:
76
87
  if activity_id in parsed_activities:
77
88
  continue
78
89
  parsed_activities.add(activity_id)
90
+ activity = self._repository.get_activity_by_id(activity_id)
91
+ if activity["kind"] != kind:
92
+ continue
79
93
  time_series = self._repository.get_time_series(activity_id)
80
94
  for _, group in time_series.groupby("segment_id"):
81
95
  xy_pixels = (
@@ -91,6 +105,16 @@ class HeatmapController:
91
105
  aim = np.array(im)
92
106
  tile_counts += aim
93
107
  np.save(tile_count_cache_path, tile_counts)
108
+ return tile_counts
109
+
110
+ def _render_tile_image(
111
+ self, x: int, y: int, z: int, kinds: list[str]
112
+ ) -> np.ndarray:
113
+ tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
114
+ tile_counts = np.zeros(tile_pixels)
115
+ for kind in kinds:
116
+ tile_counts += self._get_counts(x, y, z, kind)
117
+
94
118
  tile_counts = np.sqrt(tile_counts) / 5
95
119
  tile_counts[tile_counts > 1.0] = 1.0
96
120
 
@@ -107,16 +131,19 @@ class HeatmapController:
107
131
  ] + data_color[:, :, c]
108
132
  return map_tile
109
133
 
110
- def render_tile(self, x: int, y: int, z: int) -> bytes:
134
+ def render_tile(self, x: int, y: int, z: int, kinds: list[str]) -> bytes:
111
135
  f = io.BytesIO()
112
- pl.imsave(f, self._render_tile_image(x, y, z), format="png")
136
+ pl.imsave(f, self._render_tile_image(x, y, z, kinds), format="png")
113
137
  return bytes(f.getbuffer())
114
138
 
115
- def download_heatmap(self, north, east, south, west) -> bytes:
139
+ def download_heatmap(
140
+ self, north: float, east: float, south: float, west: float, kinds: list[str]
141
+ ) -> bytes:
116
142
  geo_bounds = GeoBounds(south, west, north, east)
117
143
  tile_bounds = get_sensible_zoom_level(geo_bounds, (4000, 4000))
144
+ pixel_bounds = PixelBounds.from_tile_bounds(tile_bounds)
118
145
 
119
- background = np.zeros((*tile_bounds.shape, 3))
146
+ background = np.zeros((*pixel_bounds.shape, 3))
120
147
  for x in range(tile_bounds.x_tile_min, tile_bounds.x_tile_max):
121
148
  for y in range(tile_bounds.y_tile_min, tile_bounds.y_tile_max):
122
149
  tile = np.array(get_tile(tile_bounds.zoom, x, y)) / 255
@@ -128,7 +155,7 @@ class HeatmapController:
128
155
  i * OSM_TILE_SIZE : (i + 1) * OSM_TILE_SIZE,
129
156
  j * OSM_TILE_SIZE : (j + 1) * OSM_TILE_SIZE,
130
157
  :,
131
- ] = self._render_tile_image(x, y, tile_bounds.zoom)
158
+ ] = self._render_tile_image(x, y, tile_bounds.zoom, kinds)
132
159
 
133
160
  f = io.BytesIO()
134
161
  pl.imsave(f, background, format="png")
@@ -7,6 +7,21 @@
7
7
  </div>
8
8
  </div>
9
9
 
10
+ <div class="row mb-3">
11
+ <div class="col">
12
+ <form action="" method="GET">
13
+ {% for kind in available_kinds %}
14
+ <div class="form-check form-check-inline form-switch">
15
+ <input class="form-check-input" type="checkbox" role="switch" id="{{ kind }}" name="kind"
16
+ value="{{ kind }}" {{ 'checked' if kind in kinds else '' }} />
17
+ <label class="form-check-label" for="{{ kind }}">{{ kind }}</label>
18
+ </div>
19
+ {% endfor %}
20
+ <button type="submit" class="btn btn-primary">Show selected kinds</button>
21
+ </form>
22
+ </div>
23
+ </div>
24
+
10
25
  <div class="row mb-3">
11
26
  <div class="col">
12
27
  <div id="heatmap" style="height: 800px;"></div>
@@ -18,7 +33,7 @@
18
33
  center: [{{ center.latitude }}, {{ center.longitude }}],
19
34
  zoom: 12
20
35
  });
21
- L.tileLayer('/heatmap/tile/{z}/{x}/{y}.png', {
36
+ L.tileLayer('/heatmap/tile/{z}/{x}/{y}/{{ kinds_str }}.png', {
22
37
  maxZoom: 19,
23
38
  attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
24
39
  }).addTo(map)
@@ -32,7 +47,7 @@
32
47
  function downloadAs() {
33
48
  bounds = map.getBounds()
34
49
  window.location.href =
35
- `/heatmap/download/${bounds.getNorth()}/${bounds.getEast()}/${bounds.getSouth()}/${bounds.getWest()}`
50
+ `/heatmap/download/${bounds.getNorth()}/${bounds.getEast()}/${bounds.getSouth()}/${bounds.getWest()}/{{ kinds_str }}`
36
51
  }
37
52
  </script>
38
53
  </div>
@@ -14,14 +14,6 @@ class SearchController:
14
14
  activities = []
15
15
  for _, row in self._repository.meta.iterrows():
16
16
  if name in row["name"]:
17
- activities.append(
18
- {
19
- "name": row["name"],
20
- "start": row["start"].isoformat(),
21
- "kind": row["kind"],
22
- "distance_km": row["distance_km"],
23
- "elapsed_time": row["elapsed_time"],
24
- }
25
- )
17
+ activities.append(row)
26
18
 
27
19
  return {"activities": activities}
@@ -0,0 +1,38 @@
1
+ from flask import Blueprint
2
+ from flask import render_template
3
+ from flask import Response
4
+
5
+ from ...core.activities import ActivityRepository
6
+ from ...explorer.tile_visits import TileVisitAccessor
7
+ from .controller import SquarePlannerController
8
+
9
+
10
+ def make_square_planner_blueprint(
11
+ repository: ActivityRepository, tile_visit_accessor: TileVisitAccessor
12
+ ) -> Blueprint:
13
+ blueprint = Blueprint("square_planner", __name__, template_folder="templates")
14
+ controller = SquarePlannerController(repository, tile_visit_accessor)
15
+
16
+ @blueprint.route("/<zoom>/<x>/<y>/<size>")
17
+ def index(zoom, x, y, size):
18
+ return render_template(
19
+ "square_planner/index.html.j2",
20
+ **controller.action_planner(int(zoom), int(x), int(y), int(size))
21
+ )
22
+
23
+ @blueprint.route("/<zoom>/<x>/<y>/<size>/missing.<suffix>")
24
+ def square_planner_missing(zoom, x, y, size, suffix: str):
25
+ mimetypes = {"geojson": "application/json", "gpx": "application/xml"}
26
+ return Response(
27
+ controller.export_missing_tiles(
28
+ int(zoom),
29
+ int(x),
30
+ int(y),
31
+ int(size),
32
+ suffix,
33
+ ),
34
+ mimetype=mimetypes[suffix],
35
+ headers={"Content-disposition": "attachment"},
36
+ )
37
+
38
+ return blueprint
File without changes
@@ -0,0 +1,16 @@
1
+ from flask import Blueprint
2
+ from flask import render_template
3
+
4
+ from ...core.activities import ActivityRepository
5
+ from .controller import SummaryController
6
+
7
+
8
+ def make_summary_blueprint(repository: ActivityRepository) -> Blueprint:
9
+ summary_controller = SummaryController(repository)
10
+ blueprint = Blueprint("summary", __name__, template_folder="templates")
11
+
12
+ @blueprint.route("/")
13
+ def index():
14
+ return render_template("summary/index.html.j2", **summary_controller.render())
15
+
16
+ return blueprint
@@ -0,0 +1,268 @@
1
+ import collections
2
+ import datetime
3
+ import functools
4
+ from typing import Optional
5
+
6
+ import altair as alt
7
+ import pandas as pd
8
+
9
+ from geo_activity_playground.core.activities import ActivityRepository
10
+ from geo_activity_playground.core.activities import make_geojson_from_time_series
11
+
12
+
13
+ class SummaryController:
14
+ def __init__(self, repository: ActivityRepository) -> None:
15
+ self._repository = repository
16
+
17
+ @functools.cache
18
+ def render(self) -> dict:
19
+ df = embellished_activities(self._repository.meta)
20
+ df = df.loc[df["consider_for_achievements"]]
21
+
22
+ year_kind_total = (
23
+ df[["year", "kind", "distance_km", "hours"]]
24
+ .groupby(["year", "kind"])
25
+ .sum()
26
+ .reset_index()
27
+ )
28
+
29
+ return {
30
+ "plot_distance_heatmap": plot_distance_heatmap(df),
31
+ "plot_monthly_distance": plot_monthly_distance(df),
32
+ "plot_yearly_distance": plot_yearly_distance(year_kind_total),
33
+ "plot_year_cumulative": plot_year_cumulative(df),
34
+ "tabulate_year_kind_mean": tabulate_year_kind_mean(df)
35
+ .reset_index()
36
+ .to_dict(orient="split"),
37
+ "plot_weekly_distance": plot_weekly_distance(df),
38
+ "nominations": [
39
+ (
40
+ self._repository.get_activity_by_id(activity_id),
41
+ reasons,
42
+ make_geojson_from_time_series(
43
+ self._repository.get_time_series(activity_id)
44
+ ),
45
+ )
46
+ for activity_id, reasons in nominate_activities(
47
+ self._repository.meta
48
+ ).items()
49
+ ],
50
+ }
51
+
52
+
53
+ def nominate_activities(meta: pd.DataFrame) -> dict[int, list[str]]:
54
+ nominations: dict[int, list[str]] = collections.defaultdict(list)
55
+
56
+ subset = meta.loc[meta["consider_for_achievements"]]
57
+
58
+ i = subset["distance_km"].idxmax()
59
+ nominations[i].append(f"Greatest distance: {meta.loc[i].distance_km:.1f} km")
60
+
61
+ i = subset["elapsed_time"].idxmax()
62
+ nominations[i].append(f"Longest elapsed time: {meta.loc[i].elapsed_time}")
63
+
64
+ i = subset["calories"].idxmax()
65
+ nominations[i].append(f"Most calories burnt: {meta.loc[i].calories:.0f} kcal")
66
+
67
+ i = subset["steps"].idxmax()
68
+ nominations[i].append(f"Most steps: {meta.loc[i].steps:.0f}")
69
+
70
+ for kind, group in meta.groupby("kind"):
71
+ for key, text in [
72
+ (
73
+ "distance_km",
74
+ lambda row: f"Greatest distance for {row.kind}: {row.distance_km:.1f} km",
75
+ ),
76
+ (
77
+ "elapsed_time",
78
+ lambda row: f"Longest elapsed time for {row.kind}: {row.elapsed_time}",
79
+ ),
80
+ (
81
+ "calories",
82
+ lambda row: f"Most calories burnt for {row.kind}: {row.calories:.0f} kcal",
83
+ ),
84
+ ("steps", lambda row: f"Most steps for {row.kind}: {row.steps:.0f}"),
85
+ ]:
86
+ series = group[key]
87
+ i = series.idxmax()
88
+ if not pd.isna(i):
89
+ nominations[i].append(text(meta.loc[i]))
90
+
91
+ return nominations
92
+
93
+
94
+ def embellished_activities(meta: pd.DataFrame) -> pd.DataFrame:
95
+ df = meta.copy()
96
+ df["year"] = [start.year for start in df["start"]]
97
+ df["month"] = [start.month for start in df["start"]]
98
+ df["day"] = [start.day for start in df["start"]]
99
+ df["week"] = [start.isocalendar().week for start in df["start"]]
100
+ df["hours"] = [
101
+ elapsed_time.total_seconds() / 3600 for elapsed_time in df["elapsed_time"]
102
+ ]
103
+ del df["elapsed_time"]
104
+ return df
105
+
106
+
107
+ def plot_distance_heatmap(meta: pd.DataFrame) -> str:
108
+ return (
109
+ alt.Chart(
110
+ meta.loc[
111
+ (
112
+ meta["start"]
113
+ >= pd.to_datetime(
114
+ datetime.datetime.now() - datetime.timedelta(days=2 * 365)
115
+ )
116
+ )
117
+ ],
118
+ title="Daily Distance Heatmap",
119
+ )
120
+ .mark_rect()
121
+ .encode(
122
+ alt.X("date(start):O", title="Day of month"),
123
+ alt.Y(
124
+ "yearmonth(start):O",
125
+ scale=alt.Scale(reverse=True),
126
+ title="Year and month",
127
+ ),
128
+ alt.Color("sum(distance_km)", scale=alt.Scale(scheme="viridis")),
129
+ [
130
+ alt.Tooltip("yearmonthdate(start)", title="Date"),
131
+ alt.Tooltip(
132
+ "sum(distance_km)", format=".1f", title="Total distance / km"
133
+ ),
134
+ alt.Tooltip("count(distance_km)", title="Number of activities"),
135
+ ],
136
+ )
137
+ .to_json(format="vega")
138
+ )
139
+
140
+
141
+ def plot_monthly_distance(meta: pd.DataFrame) -> str:
142
+ return (
143
+ alt.Chart(
144
+ meta.loc[
145
+ (
146
+ meta["start"]
147
+ >= pd.to_datetime(
148
+ datetime.datetime.now() - datetime.timedelta(days=2 * 365)
149
+ )
150
+ )
151
+ ],
152
+ title="Monthly Distance",
153
+ )
154
+ .mark_bar()
155
+ .encode(
156
+ alt.X("month(start)", title="Month"),
157
+ alt.Y("sum(distance_km)", title="Distance / km"),
158
+ alt.Color("kind", scale=alt.Scale(scheme="category10"), title="Kind"),
159
+ alt.Column("year(start):O", title="Year"),
160
+ )
161
+ .resolve_axis(x="independent")
162
+ .to_json(format="vega")
163
+ )
164
+
165
+
166
+ def plot_yearly_distance(year_kind_total: pd.DataFrame) -> str:
167
+ return (
168
+ alt.Chart(year_kind_total, title="Total Distance per Year")
169
+ .mark_bar()
170
+ .encode(
171
+ alt.X("year:O", title="Year"),
172
+ alt.Y("distance_km", title="Distance / km"),
173
+ alt.Color("kind", title="Kind"),
174
+ [
175
+ alt.Tooltip("year:O", title="Year"),
176
+ alt.Tooltip("kind", title="Kind"),
177
+ alt.Tooltip("distance_km", title="Distance / km"),
178
+ ],
179
+ )
180
+ .to_json(format="vega")
181
+ )
182
+
183
+
184
+ def plot_year_cumulative(df: pd.DataFrame) -> str:
185
+ year_cumulative = (
186
+ df[["year", "week", "distance_km"]]
187
+ .groupby("year")
188
+ .apply(
189
+ lambda group: pd.DataFrame(
190
+ {"week": group["week"], "distance_km": group["distance_km"].cumsum()}
191
+ ),
192
+ include_groups=False,
193
+ )
194
+ .reset_index()
195
+ )
196
+
197
+ return (
198
+ alt.Chart(year_cumulative, width=500, title="Cumultative Distance per Year")
199
+ .mark_line()
200
+ .encode(
201
+ alt.X("week", title="Week"),
202
+ alt.Y("distance_km", title="Distance / km"),
203
+ alt.Color("year:N", title="Year"),
204
+ [
205
+ alt.Tooltip("week", title="Week"),
206
+ alt.Tooltip("year:N", title="Year"),
207
+ alt.Tooltip("distance_km", title="Distance / km"),
208
+ ],
209
+ )
210
+ .interactive()
211
+ .to_json(format="vega")
212
+ )
213
+
214
+
215
+ def tabulate_year_kind_mean(df: pd.DataFrame) -> pd.DataFrame:
216
+ year_kind_mean = (
217
+ df[["year", "kind", "distance_km", "hours"]]
218
+ .groupby(["year", "kind"])
219
+ .mean()
220
+ .reset_index()
221
+ )
222
+
223
+ year_kind_mean_distance = year_kind_mean.pivot(
224
+ index="year", columns="kind", values="distance_km"
225
+ )
226
+
227
+ return year_kind_mean_distance
228
+
229
+
230
+ def plot_weekly_distance(df: pd.DataFrame) -> str:
231
+ week_kind_total_distance = (
232
+ df[["year", "week", "kind", "distance_km"]]
233
+ .groupby(["year", "week", "kind"])
234
+ .sum()
235
+ .reset_index()
236
+ )
237
+ week_kind_total_distance["year_week"] = [
238
+ f"{year}-{week:02d}"
239
+ for year, week in zip(
240
+ week_kind_total_distance["year"], week_kind_total_distance["week"]
241
+ )
242
+ ]
243
+
244
+ last_year = week_kind_total_distance["year"].iloc[-1]
245
+ last_week = week_kind_total_distance["week"].iloc[-1]
246
+
247
+ return (
248
+ alt.Chart(
249
+ week_kind_total_distance.loc[
250
+ (week_kind_total_distance["year"] == last_year)
251
+ | (week_kind_total_distance["year"] == last_year - 1)
252
+ & (week_kind_total_distance["week"] >= last_week)
253
+ ],
254
+ title="Weekly Distance",
255
+ )
256
+ .mark_bar()
257
+ .encode(
258
+ alt.X("year_week", title="Year and Week"),
259
+ alt.Y("distance_km", title="Distance / km"),
260
+ alt.Color("kind", title="Kind"),
261
+ [
262
+ alt.Tooltip("year_week", title="Year and Week"),
263
+ alt.Tooltip("kind", title="Kind"),
264
+ alt.Tooltip("distance_km", title="Distance / km"),
265
+ ],
266
+ )
267
+ .to_json(format="vega")
268
+ )