geo-activity-playground 0.22.0__py3-none-any.whl → 0.24.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 (77) hide show
  1. geo_activity_playground/__main__.py +1 -1
  2. geo_activity_playground/core/activities.py +16 -9
  3. geo_activity_playground/core/activity_parsers.py +17 -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 -27
  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 +6 -0
  14. geo_activity_playground/importers/strava_checkout.py +12 -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 +68 -281
  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 +4 -2
  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/{explorer_controller.py → explorer/controller.py} +6 -2
  39. geo_activity_playground/webui/{templates/explorer.html.j2 → explorer/templates/explorer/index.html.j2} +2 -2
  40. geo_activity_playground/webui/heatmap/__init__.py +0 -0
  41. geo_activity_playground/webui/heatmap/blueprint.py +41 -0
  42. geo_activity_playground/webui/{heatmap_controller.py → heatmap/heatmap_controller.py} +36 -13
  43. geo_activity_playground/webui/{templates/heatmap.html.j2 → heatmap/templates/heatmap/index.html.j2} +17 -2
  44. geo_activity_playground/webui/search_controller.py +1 -9
  45. geo_activity_playground/webui/square_planner/__init__.py +0 -0
  46. geo_activity_playground/webui/square_planner/blueprint.py +38 -0
  47. geo_activity_playground/webui/summary/__init__.py +0 -0
  48. geo_activity_playground/webui/summary/blueprint.py +16 -0
  49. geo_activity_playground/webui/summary/controller.py +268 -0
  50. geo_activity_playground/webui/summary/templates/summary/index.html.j2 +135 -0
  51. geo_activity_playground/webui/templates/{index.html.j2 → home.html.j2} +1 -1
  52. geo_activity_playground/webui/templates/page.html.j2 +32 -19
  53. geo_activity_playground/webui/templates/search.html.j2 +1 -1
  54. geo_activity_playground/webui/tile/__init__.py +0 -0
  55. geo_activity_playground/webui/tile/blueprint.py +31 -0
  56. geo_activity_playground/webui/upload/__init__.py +0 -0
  57. geo_activity_playground/webui/upload/blueprint.py +28 -0
  58. geo_activity_playground/webui/{upload_controller.py → upload/controller.py} +15 -6
  59. geo_activity_playground/webui/{templates/upload.html.j2 → upload/templates/upload/index.html.j2} +12 -11
  60. {geo_activity_playground-0.22.0.dist-info → geo_activity_playground-0.24.0.dist-info}/METADATA +2 -1
  61. geo_activity_playground-0.24.0.dist-info/RECORD +95 -0
  62. geo_activity_playground/webui/config_controller.py +0 -12
  63. geo_activity_playground/webui/locations_controller.py +0 -28
  64. geo_activity_playground/webui/summary_controller.py +0 -60
  65. geo_activity_playground/webui/templates/config.html.j2 +0 -24
  66. geo_activity_playground/webui/templates/eddington.html.j2 +0 -18
  67. geo_activity_playground/webui/templates/locations.html.j2 +0 -38
  68. geo_activity_playground/webui/templates/summary.html.j2 +0 -21
  69. geo_activity_playground-0.22.0.dist-info/RECORD +0 -74
  70. /geo_activity_playground/webui/{templates/activity-lines.html.j2 → activity/templates/activity/lines.html.j2} +0 -0
  71. /geo_activity_playground/webui/{templates/equipment.html.j2 → equipment/templates/equipment/index.html.j2} +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.22.0.dist-info → geo_activity_playground-0.24.0.dist-info}/LICENSE +0 -0
  76. {geo_activity_playground-0.22.0.dist-info → geo_activity_playground-0.24.0.dist-info}/WHEEL +0 -0
  77. {geo_activity_playground-0.22.0.dist-info → geo_activity_playground-0.24.0.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,8 @@
1
1
  import altair as alt
2
2
  import pandas as pd
3
3
 
4
- from ..core.config import get_config
5
4
  from geo_activity_playground.core.activities import ActivityRepository
5
+ from geo_activity_playground.core.config import get_config
6
6
 
7
7
 
8
8
  class EquipmentController:
@@ -18,7 +18,8 @@ class EquipmentController:
18
18
  "time": group["start"],
19
19
  "total_distance_km": group["distance_km"].cumsum(),
20
20
  }
21
- )
21
+ ),
22
+ include_groups=False,
22
23
  )
23
24
  .reset_index()
24
25
  )
@@ -52,7 +53,8 @@ class EquipmentController:
52
53
  "last_use": group["start"].iloc[-1],
53
54
  },
54
55
  index=[0],
55
- )
56
+ ),
57
+ include_groups=False,
56
58
  )
57
59
  .reset_index()
58
60
  .sort_values("last_use", ascending=False)
File without changes
@@ -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
@@ -112,8 +112,12 @@ def get_three_color_tiles(
112
112
  cmap_last = matplotlib.colormaps["plasma"]
113
113
  tile_dict = {}
114
114
  for tile, tile_data in tile_visits.items():
115
- first_age_days = (today - tile_data["first_time"].date()).days
116
- last_age_days = (today - tile_data["last_time"].date()).days
115
+ if not pd.isna(tile_data["first_time"]):
116
+ first_age_days = (today - tile_data["first_time"].date()).days
117
+ last_age_days = (today - tile_data["last_time"].date()).days
118
+ else:
119
+ first_age_days = 10000
120
+ last_age_days = 10000
117
121
  tile_dict[tile] = {
118
122
  "first_activity_id": str(tile_data["first_id"]),
119
123
  "first_activity_name": repository.get_activity_by_id(tile_data["first_id"])[
@@ -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
@@ -1,11 +1,9 @@
1
1
  import io
2
2
  import logging
3
3
  import pathlib
4
- import pickle
5
4
 
6
5
  import matplotlib.pylab as pl
7
6
  import numpy as np
8
- import pandas as pd
9
7
  from PIL import Image
10
8
  from PIL import ImageDraw
11
9
 
@@ -17,7 +15,7 @@ from geo_activity_playground.core.tasks import work_tracker
17
15
  from geo_activity_playground.core.tiles import get_tile
18
16
  from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
19
17
  from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
20
- from geo_activity_playground.webui.explorer_controller import (
18
+ from geo_activity_playground.webui.explorer.controller import (
21
19
  bounding_box_for_biggest_cluster,
22
20
  )
23
21
 
@@ -38,15 +36,22 @@ class HeatmapController:
38
36
  self.tile_histories = self._tile_visit_accessor.histories
39
37
  self.tile_evolution_states = self._tile_visit_accessor.states
40
38
  self.tile_visits = self._tile_visit_accessor.visits
39
+ self.activities_per_tile = self._tile_visit_accessor.activities_per_tile
41
40
 
42
- def render(self) -> dict:
41
+ def render(self, kinds: list[str] = []) -> dict:
43
42
  zoom = 14
44
43
  tiles = self.tile_histories[zoom]
45
- medians = tiles.median()
44
+ medians = tiles.median(skipna=True)
46
45
  median_lat, median_lon = get_tile_upper_left_lat_lon(
47
46
  medians["tile_x"], medians["tile_y"], zoom
48
47
  )
49
48
  cluster_state = self.tile_evolution_states[zoom]
49
+
50
+ available_kinds = sorted(self._repository.meta["kind"].unique())
51
+
52
+ if not kinds:
53
+ kinds = available_kinds
54
+
50
55
  return {
51
56
  "center": {
52
57
  "latitude": median_lat,
@@ -58,18 +63,21 @@ class HeatmapController:
58
63
  if len(cluster_state.memberships) > 0
59
64
  else {}
60
65
  ),
61
- }
66
+ },
67
+ "kinds": kinds,
68
+ "available_kinds": available_kinds,
69
+ "kinds_str": ";".join(kinds),
62
70
  }
63
71
 
64
- def _render_tile_image(self, x: int, y: int, z: int) -> np.ndarray:
72
+ def _get_counts(self, x: int, y: int, z: int, kind: str) -> np.ndarray:
65
73
  tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
66
- tile_count_cache_path = pathlib.Path(f"Cache/Heatmap/{z}/{x}/{y}.npy")
74
+ tile_count_cache_path = pathlib.Path(f"Cache/Heatmap/{kind}/{z}/{x}/{y}.npy")
67
75
  if tile_count_cache_path.exists():
68
76
  tile_counts = np.load(tile_count_cache_path)
69
77
  else:
70
78
  tile_counts = np.zeros(tile_pixels, dtype=np.int32)
71
79
  tile_count_cache_path.parent.mkdir(parents=True, exist_ok=True)
72
- activity_ids = self.tile_visits[z].get((x, y), {}).get("activity_ids", set())
80
+ activity_ids = self.activities_per_tile[z].get((x, y), set())
73
81
  if activity_ids:
74
82
  with work_tracker(
75
83
  tile_count_cache_path.with_suffix(".json")
@@ -78,6 +86,9 @@ class HeatmapController:
78
86
  if activity_id in parsed_activities:
79
87
  continue
80
88
  parsed_activities.add(activity_id)
89
+ activity = self._repository.get_activity_by_id(activity_id)
90
+ if activity["kind"] != kind:
91
+ continue
81
92
  time_series = self._repository.get_time_series(activity_id)
82
93
  for _, group in time_series.groupby("segment_id"):
83
94
  xy_pixels = (
@@ -93,6 +104,16 @@ class HeatmapController:
93
104
  aim = np.array(im)
94
105
  tile_counts += aim
95
106
  np.save(tile_count_cache_path, tile_counts)
107
+ return tile_counts
108
+
109
+ def _render_tile_image(
110
+ self, x: int, y: int, z: int, kinds: list[str]
111
+ ) -> np.ndarray:
112
+ tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
113
+ tile_counts = np.zeros(tile_pixels)
114
+ for kind in kinds:
115
+ tile_counts += self._get_counts(x, y, z, kind)
116
+
96
117
  tile_counts = np.sqrt(tile_counts) / 5
97
118
  tile_counts[tile_counts > 1.0] = 1.0
98
119
 
@@ -109,12 +130,14 @@ class HeatmapController:
109
130
  ] + data_color[:, :, c]
110
131
  return map_tile
111
132
 
112
- def render_tile(self, x: int, y: int, z: int) -> bytes:
133
+ def render_tile(self, x: int, y: int, z: int, kinds: list[str]) -> bytes:
113
134
  f = io.BytesIO()
114
- pl.imsave(f, self._render_tile_image(x, y, z), format="png")
135
+ pl.imsave(f, self._render_tile_image(x, y, z, kinds), format="png")
115
136
  return bytes(f.getbuffer())
116
137
 
117
- def download_heatmap(self, north, east, south, west) -> bytes:
138
+ def download_heatmap(
139
+ self, north: float, east: float, south: float, west: float, kinds: list[str]
140
+ ) -> bytes:
118
141
  geo_bounds = GeoBounds(south, west, north, east)
119
142
  tile_bounds = get_sensible_zoom_level(geo_bounds, (4000, 4000))
120
143
 
@@ -130,7 +153,7 @@ class HeatmapController:
130
153
  i * OSM_TILE_SIZE : (i + 1) * OSM_TILE_SIZE,
131
154
  j * OSM_TILE_SIZE : (j + 1) * OSM_TILE_SIZE,
132
155
  :,
133
- ] = self._render_tile_image(x, y, tile_bounds.zoom)
156
+ ] = self._render_tile_image(x, y, tile_bounds.zoom, kinds)
134
157
 
135
158
  f = io.BytesIO()
136
159
  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
+ )