geo-activity-playground 0.20.0__py3-none-any.whl → 0.21.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.
@@ -6,7 +6,6 @@ import sys
6
6
 
7
7
  import coloredlogs
8
8
 
9
- from .core.similarity import precompute_activity_distances
10
9
  from .importers.strava_checkout import convert_strava_checkout
11
10
  from .importers.strava_checkout import import_from_strava_checkout
12
11
  from geo_activity_playground.core.activities import ActivityRepository
@@ -105,17 +104,31 @@ def make_activity_repository(
105
104
  apply_cache_migrations()
106
105
  config = get_config()
107
106
 
107
+ if not config.get("prefer_metadata_from_file", True):
108
+ logger.error(
109
+ "The config option `prefer_metadata_from_file` is deprecated. If you want to prefer extract metadata from the activity file paths, please use the new `metadata_extraction_regexes` as explained at https://martin-ueding.github.io/geo-activity-playground/getting-started/using-activity-files/#directory-structure."
110
+ )
111
+ sys.exit(1)
112
+
108
113
  repository = ActivityRepository()
109
114
 
110
115
  if pathlib.Path("Activities").exists():
111
- import_from_directory(repository, config.get("prefer_metadata_from_file", True))
116
+ import_from_directory(
117
+ repository,
118
+ config.get("metadata_extraction_regexes", []),
119
+ )
112
120
  if pathlib.Path("Strava Export").exists():
113
121
  import_from_strava_checkout(repository)
114
122
  if "strava" in config and not skip_strava:
115
123
  import_from_strava_api(repository)
116
124
 
125
+ if len(repository) == 0:
126
+ logger.error(
127
+ f"No activities found. You need to either add activity files (GPX, FIT, …) to {basedir/'Activities'} or set up the Strava API. Starting without any activities is unfortunately not supported."
128
+ )
129
+ sys.exit(1)
130
+
117
131
  embellish_time_series(repository)
118
- precompute_activity_distances(repository)
119
132
  compute_tile_visits(repository)
120
133
  compute_tile_evolution()
121
134
  return repository
@@ -37,6 +37,7 @@ class ActivityMeta(TypedDict):
37
37
  start_latitude: float
38
38
  start_longitude: float
39
39
  start: datetime.datetime
40
+ steps: int
40
41
 
41
42
 
42
43
  class ActivityRepository:
@@ -163,9 +164,6 @@ def embellish_single_time_series(
163
164
  timeseries: pd.DataFrame, start: Optional[datetime.datetime] = None
164
165
  ) -> bool:
165
166
  changed = False
166
- time_diff_threshold_seconds = 30
167
- time_diff = (timeseries["time"] - timeseries["time"].shift(1)).dt.total_seconds()
168
- jump_indices = time_diff >= time_diff_threshold_seconds
169
167
 
170
168
  if start is not None and pd.api.types.is_dtype_equal(
171
169
  timeseries["time"].dtype, "int64"
@@ -176,31 +174,37 @@ def embellish_single_time_series(
176
174
  changed = True
177
175
  assert pd.api.types.is_dtype_equal(timeseries["time"].dtype, "datetime64[ns, UTC]")
178
176
 
179
- # Add distance column if missing.
180
- if "distance_km" not in timeseries.columns:
181
- distances = get_distance(
182
- timeseries["latitude"].shift(1),
183
- timeseries["longitude"].shift(1),
184
- timeseries["latitude"],
185
- timeseries["longitude"],
186
- ).fillna(0.0)
187
- distances.loc[jump_indices] = 0.0
177
+ distances = get_distance(
178
+ timeseries["latitude"].shift(1),
179
+ timeseries["longitude"].shift(1),
180
+ timeseries["latitude"],
181
+ timeseries["longitude"],
182
+ ).fillna(0.0)
183
+ time_diff_threshold_seconds = 30
184
+ time_diff = (timeseries["time"] - timeseries["time"].shift(1)).dt.total_seconds()
185
+ jump_indices = (time_diff >= time_diff_threshold_seconds) & (distances > 100)
186
+ distances.loc[jump_indices] = 0.0
187
+
188
+ if not "distance_km" in timeseries.columns:
188
189
  timeseries["distance_km"] = pd.Series(np.cumsum(distances)) / 1000
189
190
  changed = True
190
191
 
191
- if "distance_km" in timeseries.columns:
192
- if "speed" not in timeseries.columns:
193
- timeseries["speed"] = (
194
- timeseries["distance_km"].diff()
195
- / (timeseries["time"].diff().dt.total_seconds() + 1e-3)
196
- * 3600
197
- )
198
- changed = True
192
+ if "speed" not in timeseries.columns:
193
+ timeseries["speed"] = (
194
+ timeseries["distance_km"].diff()
195
+ / (timeseries["time"].diff().dt.total_seconds() + 1e-3)
196
+ * 3600
197
+ )
198
+ changed = True
199
199
 
200
- potential_jumps = (timeseries["speed"] > 40) & (timeseries["speed"].diff() > 10)
201
- if np.any(potential_jumps):
202
- timeseries = timeseries.loc[~potential_jumps]
203
- changed = True
200
+ potential_jumps = (timeseries["speed"] > 40) & (timeseries["speed"].diff() > 10)
201
+ if np.any(potential_jumps):
202
+ timeseries = timeseries.loc[~potential_jumps].copy()
203
+ changed = True
204
+
205
+ if "segment_id" not in timeseries.columns:
206
+ timeseries["segment_id"] = np.cumsum(jump_indices)
207
+ changed = True
204
208
 
205
209
  if "x" not in timeseries.columns:
206
210
  x, y = compute_tile_float(timeseries["latitude"], timeseries["longitude"], 0)
@@ -208,10 +212,6 @@ def embellish_single_time_series(
208
212
  timeseries["y"] = y
209
213
  changed = True
210
214
 
211
- if "segment_id" not in timeseries.columns:
212
- timeseries["segment_id"] = np.cumsum(jump_indices)
213
- changed = True
214
-
215
215
  return timeseries, changed
216
216
 
217
217
 
@@ -228,6 +228,11 @@ def make_geojson_from_time_series(time_series: pd.DataFrame) -> str:
228
228
 
229
229
 
230
230
  def make_geojson_color_line(time_series: pd.DataFrame) -> str:
231
+ speed_without_na = time_series["speed"].dropna()
232
+ low = min(speed_without_na)
233
+ high = max(speed_without_na)
234
+ clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
235
+
231
236
  cmap = matplotlib.colormaps["viridis"]
232
237
  features = [
233
238
  geojson.Feature(
@@ -239,7 +244,7 @@ def make_geojson_color_line(time_series: pd.DataFrame) -> str:
239
244
  ),
240
245
  properties={
241
246
  "speed": next["speed"] if np.isfinite(next["speed"]) else 0.0,
242
- "color": matplotlib.colors.to_hex(cmap(min(next["speed"] / 35, 1.0))),
247
+ "color": matplotlib.colors.to_hex(cmap(clamp_speed(next["speed"]))),
243
248
  },
244
249
  )
245
250
  for _, group in time_series.groupby("segment_id")
@@ -249,6 +254,19 @@ def make_geojson_color_line(time_series: pd.DataFrame) -> str:
249
254
  return geojson.dumps(feature_collection)
250
255
 
251
256
 
257
+ def make_speed_color_bar(time_series: pd.DataFrame) -> dict[str, str]:
258
+ speed_without_na = time_series["speed"].dropna()
259
+ low = min(speed_without_na)
260
+ high = max(speed_without_na)
261
+ cmap = matplotlib.colormaps["viridis"]
262
+ clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
263
+ colors = [
264
+ (f"{speed:.1f}", matplotlib.colors.to_hex(cmap(clamp_speed(speed))))
265
+ for speed in np.linspace(low, high, 10)
266
+ ]
267
+ return {"low": low, "high": high, "colors": colors}
268
+
269
+
252
270
  def extract_heart_rate_zones(time_series: pd.DataFrame) -> Optional[pd.DataFrame]:
253
271
  if "heartrate" not in time_series:
254
272
  return None
@@ -168,6 +168,10 @@ def read_fit_activity(path: pathlib.Path, open) -> tuple[ActivityMeta, pd.DataFr
168
168
  metadata["kind"] = str(values["sport"])
169
169
  if "sub_sport" in values:
170
170
  metadata["kind"] += " " + str(values["sub_sport"])
171
+ if "total_calories" in fields:
172
+ metadata["calories"] = values["total_calories"]
173
+ if "total_strides" in fields:
174
+ metadata["steps"] = 2 * int(values["total_strides"])
171
175
 
172
176
  return metadata, pd.DataFrame(rows)
173
177
 
@@ -1,9 +1,12 @@
1
1
  import hashlib
2
2
  import logging
3
+ import multiprocessing
3
4
  import pathlib
4
5
  import pickle
6
+ import re
5
7
  import sys
6
8
  import traceback
9
+ from typing import Optional
7
10
 
8
11
  import pandas as pd
9
12
  from tqdm import tqdm
@@ -16,16 +19,18 @@ from geo_activity_playground.core.tasks import WorkTracker
16
19
 
17
20
  logger = logging.getLogger(__name__)
18
21
 
22
+ ACTIVITY_DIR = pathlib.Path("Activities")
23
+
19
24
 
20
25
  def import_from_directory(
21
- repository: ActivityRepository, prefer_metadata_from_file: bool
26
+ repository: ActivityRepository, metadata_extraction_regexes: list[str] = []
22
27
  ) -> None:
23
28
  paths_with_errors = []
24
29
  work_tracker = WorkTracker("parse-activity-files")
25
30
 
26
31
  activity_paths = [
27
32
  path
28
- for path in pathlib.Path("Activities").rglob("*.*")
33
+ for path in ACTIVITY_DIR.rglob("*.*")
29
34
  if path.is_file() and path.suffixes and not path.stem.startswith(".")
30
35
  ]
31
36
  new_activity_paths = work_tracker.filter(activity_paths)
@@ -35,36 +40,26 @@ def import_from_directory(
35
40
  file_metadata_dir = pathlib.Path("Cache/Activity Metadata")
36
41
  file_metadata_dir.mkdir(exist_ok=True, parents=True)
37
42
 
38
- for path in tqdm(new_activity_paths, desc="Parse activity files"):
43
+ with multiprocessing.Pool() as pool:
44
+ paths_with_errors = tqdm(
45
+ pool.imap(_cache_single_file, new_activity_paths),
46
+ desc="Parse activity metadata",
47
+ total=len(new_activity_paths),
48
+ )
49
+ paths_with_errors = [error for error in paths_with_errors if error]
50
+
51
+ for path in tqdm(new_activity_paths, desc="Collate activity metadata"):
39
52
  activity_id = _get_file_hash(path)
40
- timeseries_path = activity_stream_dir / f"{activity_id}.parquet"
41
53
  file_metadata_path = file_metadata_dir / f"{activity_id}.pickle"
42
- work_tracker.mark_done(path)
43
-
44
- if not timeseries_path.exists():
45
- try:
46
- activity_meta_from_file, timeseries = read_activity(path)
47
- except ActivityParseError as e:
48
- logger.error(f"Error while parsing file {path}:")
49
- traceback.print_exc()
50
- paths_with_errors.append((path, str(e)))
51
- continue
52
- except:
53
- logger.error(f"Encountered a problem with {path=}, see details below.")
54
- raise
55
-
56
- if len(timeseries) == 0:
57
- continue
58
-
59
- timeseries.to_parquet(timeseries_path)
60
- with open(file_metadata_path, "wb") as f:
61
- pickle.dump(activity_meta_from_file, f)
62
- else:
63
- with open(file_metadata_path, "rb") as f:
64
- activity_meta_from_file = pickle.load(f)
54
+ work_tracker.mark_done(activity_id)
55
+
56
+ if not file_metadata_path.exists():
57
+ continue
58
+
59
+ with open(file_metadata_path, "rb") as f:
60
+ activity_meta_from_file = pickle.load(f)
65
61
 
66
62
  activity_meta = ActivityMeta(
67
- commute=path.parts[-2] == "Commute",
68
63
  id=activity_id,
69
64
  # https://stackoverflow.com/a/74718395/653152
70
65
  name=path.name.removesuffix("".join(path.suffixes)),
@@ -72,15 +67,8 @@ def import_from_directory(
72
67
  kind="Unknown",
73
68
  equipment="Unknown",
74
69
  )
75
- if len(path.parts) >= 3 and path.parts[1] != "Commute":
76
- activity_meta["kind"] = path.parts[1]
77
- if len(path.parts) >= 4 and path.parts[2] != "Commute":
78
- activity_meta["equipment"] = path.parts[2]
79
-
80
- if prefer_metadata_from_file:
81
- activity_meta = {**activity_meta, **activity_meta_from_file}
82
- else:
83
- activity_meta = {**activity_meta_from_file, **activity_meta}
70
+ activity_meta.update(activity_meta_from_file)
71
+ activity_meta.update(_get_metadata_from_path(path, metadata_extraction_regexes))
84
72
  repository.add_activity(activity_meta)
85
73
 
86
74
  if paths_with_errors:
@@ -95,9 +83,45 @@ def import_from_directory(
95
83
  work_tracker.close()
96
84
 
97
85
 
86
+ def _cache_single_file(path: pathlib.Path) -> Optional[tuple[pathlib.Path, str]]:
87
+ activity_stream_dir = pathlib.Path("Cache/Activity Timeseries")
88
+ file_metadata_dir = pathlib.Path("Cache/Activity Metadata")
89
+
90
+ activity_id = _get_file_hash(path)
91
+ timeseries_path = activity_stream_dir / f"{activity_id}.parquet"
92
+ file_metadata_path = file_metadata_dir / f"{activity_id}.pickle"
93
+
94
+ if not timeseries_path.exists():
95
+ try:
96
+ activity_meta_from_file, timeseries = read_activity(path)
97
+ except ActivityParseError as e:
98
+ logger.error(f"Error while parsing file {path}:")
99
+ traceback.print_exc()
100
+ return (path, str(e))
101
+ except:
102
+ logger.error(f"Encountered a problem with {path=}, see details below.")
103
+ raise
104
+
105
+ if len(timeseries) == 0:
106
+ return
107
+
108
+ timeseries.to_parquet(timeseries_path)
109
+ with open(file_metadata_path, "wb") as f:
110
+ pickle.dump(activity_meta_from_file, f)
111
+
112
+
98
113
  def _get_file_hash(path: pathlib.Path) -> int:
99
114
  file_hash = hashlib.blake2s()
100
115
  with open(path, "rb") as f:
101
116
  while chunk := f.read(8192):
102
117
  file_hash.update(chunk)
103
118
  return int(file_hash.hexdigest(), 16) % 2**62
119
+
120
+
121
+ def _get_metadata_from_path(
122
+ path: pathlib.Path, metadata_extraction_regexes: list[str]
123
+ ) -> dict[str, str]:
124
+ for regex in metadata_extraction_regexes:
125
+ if m := re.search(regex, str(path.relative_to(ACTIVITY_DIR))):
126
+ return m.groupdict()
127
+ return {}
@@ -3,6 +3,8 @@ import logging
3
3
  import pathlib
4
4
  import shutil
5
5
  import traceback
6
+ from typing import Optional
7
+ from typing import Union
6
8
 
7
9
  import dateutil.parser
8
10
  import numpy as np
@@ -116,6 +118,13 @@ EXPECTED_COLUMNS = [
116
118
  ]
117
119
 
118
120
 
121
+ def float_or_none(x: Union[float, str]) -> Optional[float]:
122
+ try:
123
+ return float(x)
124
+ except ValueError:
125
+ return None
126
+
127
+
119
128
  def import_from_strava_checkout(repository: ActivityRepository) -> None:
120
129
  checkout_path = pathlib.Path("Strava Export")
121
130
  activities = pd.read_csv(checkout_path / "activities.csv")
@@ -143,7 +152,7 @@ def import_from_strava_checkout(repository: ActivityRepository) -> None:
143
152
  row = activities.loc[activity_id]
144
153
  activity_file = checkout_path / row["Filename"]
145
154
  table_activity_meta = {
146
- "calories": row["Calories"],
155
+ "calories": float_or_none(row["Calories"]),
147
156
  "commute": row["Commute"] == "true",
148
157
  "distance_km": row["Distance"],
149
158
  "elapsed_time": datetime.timedelta(seconds=int(row["Elapsed Time"])),
@@ -0,0 +1,22 @@
1
+ import pathlib
2
+
3
+ from geo_activity_playground.importers.directory import _get_metadata_from_path
4
+
5
+
6
+ def test_get_metadata_from_path() -> None:
7
+ expected = {
8
+ "kind": "Radfahrt",
9
+ "equipment": "Bike 2019",
10
+ "name": "Foo-Bar to Baz24",
11
+ }
12
+ actual = _get_metadata_from_path(
13
+ pathlib.Path(
14
+ "Activities/Radfahrt/Bike 2019/Foo-Bar to Baz24/2024-03-03-17-42-10 Something something.fit"
15
+ ),
16
+ [
17
+ r"(?P<kind>[^/]+)/(?P<equipment>[^/]+)/(?P<name>[^/]+)/",
18
+ r"(?P<kind>[^/]+)/(?P<equipment>[^/]+)/[-\d_ ]+(?P<name>[^/\.]+)(?:\.\w+)+$",
19
+ r"(?P<kind>[^/]+)/[-\d_ ]+(?P<name>[^/\.]+)(?:\.\w+)+$",
20
+ ],
21
+ )
22
+ assert actual == expected
@@ -1,8 +1,12 @@
1
+ import datetime
1
2
  import functools
2
3
  import io
4
+ import logging
3
5
  import pickle
4
6
 
5
7
  import altair as alt
8
+ import geojson
9
+ import matplotlib
6
10
  import matplotlib.pyplot as pl
7
11
  import numpy as np
8
12
  import pandas as pd
@@ -13,15 +17,17 @@ from geo_activity_playground.core.activities import ActivityRepository
13
17
  from geo_activity_playground.core.activities import extract_heart_rate_zones
14
18
  from geo_activity_playground.core.activities import make_geojson_color_line
15
19
  from geo_activity_playground.core.activities import make_geojson_from_time_series
20
+ from geo_activity_playground.core.activities import make_speed_color_bar
16
21
  from geo_activity_playground.core.heatmap import add_margin_to_geo_bounds
17
22
  from geo_activity_playground.core.heatmap import build_map_from_tiles
18
23
  from geo_activity_playground.core.heatmap import crop_image_to_bounds
19
24
  from geo_activity_playground.core.heatmap import get_bounds
20
25
  from geo_activity_playground.core.heatmap import get_sensible_zoom_level
21
26
  from geo_activity_playground.core.heatmap import OSM_TILE_SIZE
22
- from geo_activity_playground.core.similarity import distances_path
23
27
  from geo_activity_playground.core.tiles import compute_tile_float
24
28
 
29
+ logger = logging.getLogger(__name__)
30
+
25
31
 
26
32
  class ActivityController:
27
33
  def __init__(self, repository: ActivityRepository) -> None:
@@ -34,16 +40,12 @@ class ActivityController:
34
40
  time_series = self._repository.get_time_series(id)
35
41
  line_json = make_geojson_from_time_series(time_series)
36
42
 
37
- with open(distances_path, "rb") as f:
38
- distances = pickle.load(f)
39
-
40
- similar_activites = {
41
- distance: [
42
- self._repository.get_activity_by_id(activity_id)
43
- for activity_id in distances[activity.id].get(distance, set())
44
- ]
45
- for distance in range(10)
46
- }
43
+ meta = self._repository.meta
44
+ similar_activities = meta.loc[
45
+ (meta.name == activity["name"]) & (meta.id != activity["id"])
46
+ ]
47
+ similar_activities = [row for _, row in similar_activities.iterrows()]
48
+ similar_activities.reverse()
47
49
 
48
50
  result = {
49
51
  "activity": activity,
@@ -52,7 +54,10 @@ class ActivityController:
52
54
  "color_line_geojson": make_geojson_color_line(time_series),
53
55
  "speed_time_plot": speed_time_plot(time_series),
54
56
  "speed_distribution_plot": speed_distribution_plot(time_series),
55
- "similar_activites": similar_activites,
57
+ "similar_activites": similar_activities,
58
+ "speed_color_bar": make_speed_color_bar(time_series),
59
+ "date": activity.start.date(),
60
+ "time": activity.start.time(),
56
61
  }
57
62
  if (heart_zones := extract_heart_rate_zones(time_series)) is not None:
58
63
  result["heart_zones_plot"] = heartrate_zone_plot(heart_zones)
@@ -66,6 +71,124 @@ class ActivityController:
66
71
  time_series = self._repository.get_time_series(id)
67
72
  return make_sharepic(time_series)
68
73
 
74
+ def render_day(self, year: int, month: int, day: int) -> dict:
75
+ meta = self._repository.meta
76
+ selection = meta["start"].dt.date == datetime.date(year, month, day)
77
+ activities_that_day = meta.loc[selection]
78
+
79
+ time_series = [
80
+ self._repository.get_time_series(activity_id)
81
+ for activity_id in activities_that_day["id"]
82
+ ]
83
+
84
+ cmap = matplotlib.colormaps["Dark2"]
85
+ fc = geojson.FeatureCollection(
86
+ features=[
87
+ geojson.Feature(
88
+ geometry=geojson.MultiLineString(
89
+ coordinates=[
90
+ [
91
+ [lon, lat]
92
+ for lat, lon in zip(
93
+ group["latitude"], group["longitude"]
94
+ )
95
+ ]
96
+ for _, group in ts.groupby("segment_id")
97
+ ]
98
+ ),
99
+ properties={"color": matplotlib.colors.to_hex(cmap(i % 8))},
100
+ )
101
+ for i, ts in enumerate(time_series)
102
+ ]
103
+ )
104
+
105
+ activities_list = activities_that_day.to_dict(orient="records")
106
+ for i, activity_record in enumerate(activities_list):
107
+ activity_record["color"] = matplotlib.colors.to_hex(cmap(i % 8))
108
+
109
+ return {
110
+ "activities": activities_list,
111
+ "geojson": geojson.dumps(fc),
112
+ "date": datetime.date(year, month, day).isoformat(),
113
+ }
114
+
115
+ def render_all(self) -> dict:
116
+ cmap = matplotlib.colormaps["Dark2"]
117
+ fc = geojson.FeatureCollection(
118
+ features=[
119
+ geojson.Feature(
120
+ geometry=geojson.MultiLineString(
121
+ coordinates=[
122
+ [
123
+ [lon, lat]
124
+ for lat, lon in zip(
125
+ group["latitude"], group["longitude"]
126
+ )
127
+ ]
128
+ for _, group in self._repository.get_time_series(
129
+ activity["id"]
130
+ ).groupby("segment_id")
131
+ ]
132
+ ),
133
+ properties={
134
+ "color": matplotlib.colors.to_hex(cmap(i % 8)),
135
+ "activity_name": activity["name"],
136
+ "activity_id": str(activity["id"]),
137
+ },
138
+ )
139
+ for i, activity in enumerate(self._repository.iter_activities())
140
+ ]
141
+ )
142
+
143
+ return {
144
+ "geojson": geojson.dumps(fc),
145
+ }
146
+
147
+ def render_name(self, name: str) -> dict:
148
+ meta = self._repository.meta
149
+ selection = meta["name"] == name
150
+ activities_with_name = meta.loc[selection]
151
+
152
+ time_series = [
153
+ self._repository.get_time_series(activity_id)
154
+ for activity_id in activities_with_name["id"]
155
+ ]
156
+
157
+ cmap = matplotlib.colormaps["Dark2"]
158
+ fc = geojson.FeatureCollection(
159
+ features=[
160
+ geojson.Feature(
161
+ geometry=geojson.MultiLineString(
162
+ coordinates=[
163
+ [
164
+ [lon, lat]
165
+ for lat, lon in zip(
166
+ group["latitude"], group["longitude"]
167
+ )
168
+ ]
169
+ for _, group in ts.groupby("segment_id")
170
+ ]
171
+ ),
172
+ properties={"color": matplotlib.colors.to_hex(cmap(i % 8))},
173
+ )
174
+ for i, ts in enumerate(time_series)
175
+ ]
176
+ )
177
+
178
+ activities_list = activities_with_name.to_dict(orient="records")
179
+ for i, activity_record in enumerate(activities_list):
180
+ activity_record["color"] = matplotlib.colors.to_hex(cmap(i % 8))
181
+
182
+ return {
183
+ "activities": activities_list,
184
+ "geojson": geojson.dumps(fc),
185
+ "name": name,
186
+ "tick_plot": name_tick_plot(activities_with_name),
187
+ "equipment_plot": name_equipment_plot(activities_with_name),
188
+ "distance_plot": name_distance_plot(activities_with_name),
189
+ "minutes_plot": name_minutes_plot(activities_with_name),
190
+ }
191
+
69
192
 
70
193
  def speed_time_plot(time_series: pd.DataFrame) -> str:
71
194
  return (
@@ -154,6 +277,51 @@ def heartrate_zone_plot(heart_zones: pd.DataFrame) -> str:
154
277
  )
155
278
 
156
279
 
280
+ def name_tick_plot(meta: pd.DataFrame) -> str:
281
+ return (
282
+ alt.Chart(meta, title="Repetitions")
283
+ .mark_tick()
284
+ .encode(
285
+ alt.X("start", title="Date"),
286
+ )
287
+ .to_json(format="vega")
288
+ )
289
+
290
+
291
+ def name_equipment_plot(meta: pd.DataFrame) -> str:
292
+ return (
293
+ alt.Chart(meta, title="Equipment")
294
+ .mark_bar()
295
+ .encode(alt.X("count()", title="Count"), alt.Y("equipment", title="Equipment"))
296
+ .to_json(format="vega")
297
+ )
298
+
299
+
300
+ def name_distance_plot(meta: pd.DataFrame) -> str:
301
+ return (
302
+ alt.Chart(meta, title="Distance")
303
+ .mark_bar()
304
+ .encode(
305
+ alt.X("distance_km", bin=True, title="Distance / km"),
306
+ alt.Y("count()", title="Count"),
307
+ )
308
+ .to_json(format="vega")
309
+ )
310
+
311
+
312
+ def name_minutes_plot(meta: pd.DataFrame) -> str:
313
+ minutes = meta["elapsed_time"].dt.total_seconds() / 60
314
+ return (
315
+ alt.Chart(pd.DataFrame({"minutes": minutes}), title="Elapsed time")
316
+ .mark_bar()
317
+ .encode(
318
+ alt.X("minutes", bin=True, title="Time / min"),
319
+ alt.Y("count()", title="Count"),
320
+ )
321
+ .to_json(format="vega")
322
+ )
323
+
324
+
157
325
  def make_sharepic(time_series: pd.DataFrame) -> bytes:
158
326
  lat_lon_data = np.array([time_series["latitude"], time_series["longitude"]]).T
159
327
 
@@ -1,3 +1,5 @@
1
+ import urllib
2
+
1
3
  from flask import Flask
2
4
  from flask import redirect
3
5
  from flask import render_template
@@ -29,6 +31,12 @@ from geo_activity_playground.webui.tile_controller import (
29
31
  def route_activity(app: Flask, repository: ActivityRepository) -> None:
30
32
  activity_controller = ActivityController(repository)
31
33
 
34
+ @app.route("/activity/all")
35
+ def activity_all():
36
+ return render_template(
37
+ "activity-lines.html.j2", **activity_controller.render_all()
38
+ )
39
+
32
40
  @app.route("/activity/<id>")
33
41
  def activity(id: str):
34
42
  return render_template(
@@ -42,6 +50,20 @@ def route_activity(app: Flask, repository: ActivityRepository) -> None:
42
50
  mimetype="image/png",
43
51
  )
44
52
 
53
+ @app.route("/activity/day/<year>/<month>/<day>")
54
+ def activity_day(year: str, month: str, day: str):
55
+ return render_template(
56
+ "activity-day.html.j2",
57
+ **activity_controller.render_day(int(year), int(month), int(day))
58
+ )
59
+
60
+ @app.route("/activity/name/<name>")
61
+ def activity_name(name: str):
62
+ return render_template(
63
+ "activity-name.html.j2",
64
+ **activity_controller.render_name(urllib.parse.unquote(name))
65
+ )
66
+
45
67
 
46
68
  def route_calendar(app: Flask, repository: ActivityRepository) -> None:
47
69
  calendar_controller = CalendarController(repository)
@@ -53,7 +75,7 @@ def route_calendar(app: Flask, repository: ActivityRepository) -> None:
53
75
  )
54
76
 
55
77
  @app.route("/calendar/<year>/<month>")
56
- def calendar_year_month(year: str, month: str):
78
+ def calendar_month(year: str, month: str):
57
79
  return render_template(
58
80
  "calendar-month.html.j2",
59
81
  **calendar_controller.render_month(int(year), int(month))
@@ -264,8 +286,7 @@ def route_tiles(app: Flask, repository: ActivityRepository) -> None:
264
286
  @app.route("/tile/color/<z>/<x>/<y>.png")
265
287
  def tile_color(x: str, y: str, z: str):
266
288
  return Response(
267
- tile_controller.render_color(int(x), int(y), int(z)),
268
- mimetype="image/png",
289
+ tile_controller.render_color(int(x), int(y), int(z)), mimetype="image/png"
269
290
  )
270
291
 
271
292
  @app.route("/tile/grayscale/<z>/<x>/<y>.png")
@@ -275,6 +296,12 @@ def route_tiles(app: Flask, repository: ActivityRepository) -> None:
275
296
  mimetype="image/png",
276
297
  )
277
298
 
299
+ @app.route("/tile/pastel/<z>/<x>/<y>.png")
300
+ def tile_pastel(x: str, y: str, z: str):
301
+ return Response(
302
+ tile_controller.render_pastel(int(x), int(y), int(z)), mimetype="image/png"
303
+ )
304
+
278
305
 
279
306
  def webui_main(repository: ActivityRepository, host: str, port: int) -> None:
280
307
  app = Flask(__name__)
@@ -2,8 +2,6 @@ import collections
2
2
  import datetime
3
3
  import functools
4
4
 
5
- import pandas as pd
6
-
7
5
  from geo_activity_playground.core.activities import ActivityRepository
8
6
 
9
7
 
@@ -58,11 +56,12 @@ class CalendarController:
58
56
  ].sort_values("start")
59
57
 
60
58
  weeks = collections.defaultdict(dict)
61
-
59
+ day_of_month = collections.defaultdict(dict)
62
60
  date = datetime.datetime(year, month, 1)
63
61
  while date.month == month:
64
62
  iso = date.isocalendar()
65
63
  weeks[iso.week][iso.weekday] = []
64
+ day_of_month[iso.week][iso.weekday] = date.day
66
65
  date += datetime.timedelta(days=1)
67
66
 
68
67
  for index, row in filtered.iterrows():
@@ -76,4 +75,9 @@ class CalendarController:
76
75
  }
77
76
  )
78
77
 
79
- return {"year": year, "month": month, "weeks": weeks}
78
+ return {
79
+ "year": year,
80
+ "month": month,
81
+ "weeks": weeks,
82
+ "day_of_month": day_of_month,
83
+ }
@@ -0,0 +1,71 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+ <div class="row mb-3">
5
+ <div class="col">
6
+ <h1>{{ date }}</h1>
7
+ </div>
8
+ </div>
9
+
10
+
11
+ <div class="row mb-3">
12
+ <div class="col-md-9">
13
+ <div id="activity-map" style="height: 500px;"></div>
14
+ <script>
15
+ var map = L.map('activity-map', {
16
+ fullscreenControl: true
17
+ });
18
+ L.tileLayer('/tile/grayscale/{z}/{x}/{y}.png', {
19
+ maxZoom: 19,
20
+ attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
21
+ }).addTo(map);
22
+
23
+ let geojson = L.geoJSON({{ geojson| safe }}, {
24
+ style: function (feature) { return { color: feature.properties.color } }
25
+ }).addTo(map)
26
+ map.fitBounds(geojson.getBounds());
27
+ </script>
28
+ </div>
29
+ <div class="col-md-3">
30
+ <ol>
31
+ {% for activity in activities %}
32
+ <li><span style="color: {{ activity['color'] }};">█</span> <a href="/activity/{{ activity.id }}">{{
33
+ activity.name }}</a></li>
34
+ {% endfor %}
35
+ </ol>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="row mb-3">
40
+ <div class="col">
41
+ <h2>Activities</h2>
42
+
43
+ <table class="table">
44
+ <thead>
45
+ <tr>
46
+ <th>Name</th>
47
+ <th>Date</th>
48
+ <th>Distance / km</th>
49
+ <th>Elapsed time</th>
50
+ <th>Equipment</th>
51
+ <th>Kind</th>
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ {% for activity in activities %}
56
+ <tr>
57
+ <td><span style="color: {{ activity['color'] }};">█</span> <a href="/activity/{{ activity.id }}">{{
58
+ activity.name }}</a></td>
59
+ <td>{{ activity.start }}</td>
60
+ <td>{{ activity.distance_km | round(1) }}</td>
61
+ <td>{{ activity.elapsed_time }}</td>
62
+ <td>{{ activity["equipment"] }}</td>
63
+ <td>{{ activity["kind"] }}</td>
64
+ </tr>
65
+ {% endfor %}
66
+ </tbody>
67
+ </table>
68
+ </div>
69
+ </div>
70
+
71
+ {% endblock %}
@@ -0,0 +1,36 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+
5
+ <div class="row mb-3">
6
+ <div class="col">
7
+ <h1>All Activity Lines</h1>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="row mb-3">
12
+ <div class="col-md-12">
13
+ <div id="activity-map" style="height: 500px;"></div>
14
+ <script>
15
+ function onEachFeature(feature, layer) {
16
+ layer.bindPopup(`<a href=/activity/${feature.properties.activity_id}>${feature.properties.activity_name}</a>`)
17
+ }
18
+
19
+ var map = L.map('activity-map', {
20
+ fullscreenControl: true
21
+ });
22
+ L.tileLayer('/tile/grayscale/{z}/{x}/{y}.png', {
23
+ maxZoom: 19,
24
+ attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
25
+ }).addTo(map);
26
+
27
+ let geojson = L.geoJSON({{ geojson| safe }}, {
28
+ style: function (feature) { return { color: feature.properties.color } },
29
+ onEachFeature: onEachFeature
30
+ }).addTo(map)
31
+ map.fitBounds(geojson.getBounds());
32
+ </script>
33
+ </div>
34
+ </div>
35
+
36
+ {% endblock %}
@@ -0,0 +1,81 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+ <div class="row mb-3">
5
+ <div class="col">
6
+ <h1>{{ name }}</h1>
7
+ </div>
8
+ </div>
9
+
10
+
11
+ <div class="row mb-3">
12
+ <div class="col-md-12">
13
+ <div id="activity-map" style="height: 500px;"></div>
14
+ <script>
15
+ var map = L.map('activity-map', {
16
+ fullscreenControl: true
17
+ });
18
+ L.tileLayer('/tile/grayscale/{z}/{x}/{y}.png', {
19
+ maxZoom: 19,
20
+ attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
21
+ }).addTo(map);
22
+
23
+ let geojson = L.geoJSON({{ geojson| safe }}, {
24
+ style: function (feature) { return { color: feature.properties.color } }
25
+ }).addTo(map)
26
+ map.fitBounds(geojson.getBounds());
27
+ </script>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="row mb-3">
32
+ <div class="col-md-4">
33
+ {{ vega_direct("tick_plot", tick_plot) }}
34
+ </div>
35
+ <div class="col-md-4">
36
+ {{ vega_direct("equipment_plot", equipment_plot) }}
37
+ </div>
38
+ </div>
39
+
40
+ <div class="row mb-3">
41
+ <div class="col-md-4">
42
+ {{ vega_direct("distance_plot", distance_plot) }}
43
+ </div>
44
+ <div class="col-md-4">
45
+ {{ vega_direct("minutes_plot", minutes_plot) }}
46
+ </div>
47
+ </div>
48
+
49
+ <div class="row mb-3">
50
+ <div class="col">
51
+ <h2>Activities</h2>
52
+
53
+ <table class="table">
54
+ <thead>
55
+ <tr>
56
+ <th>Name</th>
57
+ <th>Date</th>
58
+ <th>Distance / km</th>
59
+ <th>Elapsed time</th>
60
+ <th>Equipment</th>
61
+ <th>Kind</th>
62
+ </tr>
63
+ </thead>
64
+ <tbody>
65
+ {% for activity in activities %}
66
+ <tr>
67
+ <td><span style="color: {{ activity['color'] }};">█</span> <a href="/activity/{{ activity.id }}">{{
68
+ activity.name }}</a></td>
69
+ <td>{{ activity.start }}</td>
70
+ <td>{{ activity.distance_km | round(1) }}</td>
71
+ <td>{{ activity.elapsed_time }}</td>
72
+ <td>{{ activity["equipment"] }}</td>
73
+ <td>{{ activity["kind"] }}</td>
74
+ </tr>
75
+ {% endfor %}
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+ </div>
80
+
81
+ {% endblock %}
@@ -21,9 +21,12 @@
21
21
  <dt>Elapsed time</dt>
22
22
  <dd>{{ activity.elapsed_time }}</dd>
23
23
  <dt>Start time</dt>
24
- <dd>{{ activity.start }}</dd>
24
+ <dd><a href="/activity/day/{{ date.year }}/{{ date.month }}/{{ date.day }}">{{ date }}</a> {{ time }}
25
+ </dd>
25
26
  <dt>Calories</dt>
26
27
  <dd>{{ activity.calories }}</dd>
28
+ <dt>Steps</dt>
29
+ <dd>{{ activity.steps }}</dd>
27
30
  <dt>Equipment</dt>
28
31
  <dd>{{ activity.equipment }}</dd>
29
32
  <dt>ID</dt>
@@ -33,12 +36,12 @@
33
36
  </dl>
34
37
  </div>
35
38
  <div class="col-8">
36
- <div id="activity-map" style="height: 500px;"></div>
39
+ <div id="activity-map" style="height: 500px;" class="mb-3"></div>
37
40
  <script>
38
41
  var map = L.map('activity-map', {
39
42
  fullscreenControl: true
40
43
  });
41
- L.tileLayer('/tile/color/{z}/{x}/{y}.png', {
44
+ L.tileLayer('/tile/pastel/{z}/{x}/{y}.png', {
42
45
  maxZoom: 19,
43
46
  attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
44
47
  }).addTo(map);
@@ -48,6 +51,24 @@
48
51
  }).addTo(map)
49
52
  map.fitBounds(geojson.getBounds());
50
53
  </script>
54
+
55
+
56
+ <style>
57
+ span.colorbar {
58
+ text-shadow: -1px -1px 0 black, -1px 0px 0 black, -1px 1px 0 black, 0px -1px 0 black, 0px 0px 0 black, 0px 1px 0 black, 1px -1px 0 black, 1px 0px 0 black, 1px 1px 0 black;
59
+ padding-left: 5px;
60
+ padding-right: 5px;
61
+ line-height: 130%;
62
+ color: white;
63
+ }
64
+ </style>
65
+
66
+ <div>
67
+ {% for speed, color in speed_color_bar.colors %}
68
+ <span class="colorbar" style="width: 15px; background-color: {{ color }}">{{ speed }}</span>
69
+ {% endfor %}
70
+ km/h
71
+ </div>
51
72
  </div>
52
73
  </div>
53
74
 
@@ -109,35 +130,38 @@
109
130
  </div>
110
131
  </div>
111
132
 
133
+ {% if similar_activites|length > 0 %}
112
134
  <div class="row mb-3">
113
135
  <div class="col">
114
- <h2>Similar activities</h2>
136
+ <h2>Activities with the same name</h2>
137
+
138
+ <p><a href="/activity/name/{{ activity['name']|urlencode() }}">Overview over these activities</a></p>
115
139
 
116
140
  <table class="table">
117
141
  <thead>
118
142
  <tr>
119
- <th>Hamming distance</th>
120
- <th>Name</th>
121
143
  <th>Date</th>
122
144
  <th>Distance / km</th>
123
145
  <th>Elapsed time</th>
146
+ <th>Equipment</th>
147
+ <th>Kind</th>
124
148
  </tr>
125
149
  </thead>
126
150
  <tbody>
127
- {% for distance, other_activities in similar_activites.items() %}
128
- {% for other_activity in other_activities %}
151
+ {% for other_activity in similar_activites %}
129
152
  <tr>
130
- <td>{{ distance }}</td>
131
- <td><a href="/activity/{{ other_activity.id }}">{{ other_activity["name"] }}</a></td>
132
- <td>{{ other_activity.start }}</td>
153
+ <td><a href="/activity/{{ other_activity.id }}">{{ other_activity.start
154
+ }}</a></td>
133
155
  <td>{{ other_activity.distance_km | round(1) }}</td>
134
156
  <td>{{ other_activity.elapsed_time }}</td>
157
+ <td>{{ other_activity["equipment"] }}</td>
158
+ <td>{{ other_activity["kind"] }}</td>
135
159
  </tr>
136
160
  {% endfor %}
137
- {% endfor %}
138
161
  </tbody>
139
162
  </table>
140
163
  </div>
141
164
  </div>
165
+ {% endif %}
142
166
 
143
167
  {% endblock %}
@@ -28,6 +28,13 @@
28
28
  <td>{{ week }}</td>
29
29
  {% for day in range(1, 8) %}
30
30
  <td>
31
+ {% if weeks[week][day] %}
32
+ <a href="/activity/day/{{ year }}/{{ month }}/{{ day_of_month[week][day] }}"><b>{{
33
+ day_of_month[week][day] }}.</b></a>
34
+ {% elif day_of_month[week][day] %}
35
+ <b>{{ day_of_month[week][day] }}.</b>
36
+ {% endif %}
37
+
31
38
  {% if weeks[week][day] %}
32
39
  <ul>
33
40
  {% for activity in weeks[week][day] %}
@@ -20,3 +20,13 @@ class TileController:
20
20
  f = io.BytesIO()
21
21
  pl.imsave(f, map_tile, format="png")
22
22
  return bytes(f.getbuffer())
23
+
24
+ def render_pastel(self, x: int, y: int, z: int) -> bytes:
25
+ map_tile = np.array(get_tile(z, x, y)) / 255
26
+ averaged_tile = np.sum(map_tile * [0.2126, 0.7152, 0.0722], axis=2)
27
+ grayscale_tile = np.dstack((averaged_tile, averaged_tile, averaged_tile))
28
+ factor = 0.7
29
+ pastel_tile = factor * grayscale_tile + (1 - factor) * map_tile
30
+ f = io.BytesIO()
31
+ pl.imsave(f, pastel_tile, format="png")
32
+ return bytes(f.getbuffer())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geo-activity-playground
3
- Version: 0.20.0
3
+ Version: 0.21.0
4
4
  Summary: Analysis of geo data activities like rides, runs or hikes.
5
5
  License: MIT
6
6
  Author: Martin Ueding
@@ -20,7 +20,6 @@ Requires-Dist: fitdecode (>=0.10.0,<0.11.0)
20
20
  Requires-Dist: flask (>=3.0.0,<4.0.0)
21
21
  Requires-Dist: geojson (>=3.0.1,<4.0.0)
22
22
  Requires-Dist: gpxpy (>=1.5.0,<2.0.0)
23
- Requires-Dist: imagehash (>=4.3.1,<5.0.0)
24
23
  Requires-Dist: jinja2 (>=3.1.2,<4.0.0)
25
24
  Requires-Dist: matplotlib (>=3.6.3,<4.0.0)
26
25
  Requires-Dist: numpy (>=1.22.4,<2.0.0)
@@ -1,8 +1,8 @@
1
1
  geo_activity_playground/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- geo_activity_playground/__main__.py,sha256=_HgWroTbozlbWUipBdIrcqMxLB4nd5OdPcDD-8kDlq4,4337
2
+ geo_activity_playground/__main__.py,sha256=z8CaA2SgW7PQ4peK-D5Z6JXz79k3v3GMGQsYS28TjlA,4990
3
3
  geo_activity_playground/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- geo_activity_playground/core/activities.py,sha256=-hMSewcvhZ_93Fpr9BhYFrwIfK9ooZChbJ6V8nF1x0A,10380
5
- geo_activity_playground/core/activity_parsers.py,sha256=2EurI33yTZRF0MaFJa5FsyQLQW4jNsV_I7RViJ4y9zE,11190
4
+ geo_activity_playground/core/activities.py,sha256=-2stf_xJ1C2j-KV08wj5Y-uuuV6w01RN3bh8Gvuti1I,10986
5
+ geo_activity_playground/core/activity_parsers.py,sha256=GYDaKi5lFo5YIThoRc0enmwwgWkTfJKCxiPsSkpHBxw,11440
6
6
  geo_activity_playground/core/cache_migrations.py,sha256=cz7zwoYtjAcFbUQee1UqeyHT0K2oiyfpPVh5tXkzk0U,3479
7
7
  geo_activity_playground/core/config.py,sha256=YjqCiEmIAa-GM1-JfBctMEsl8-I56pZyyDdTyPduOzw,477
8
8
  geo_activity_playground/core/coordinates.py,sha256=tDfr9mlXhK6E_MMIJ0vYWVCoH0Lq8uyuaqUgaa8i0jg,966
@@ -16,13 +16,14 @@ geo_activity_playground/explorer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRk
16
16
  geo_activity_playground/explorer/grid_file.py,sha256=0PCFd6WE1uL3PXwZNeseURuFY7ImEVREoZpTObKiU1U,3258
17
17
  geo_activity_playground/explorer/tile_visits.py,sha256=z6ExfywFNOQ6dh2NZ9wOR_I3XkAvByIaM8O27WM5hH4,10745
18
18
  geo_activity_playground/explorer/video.py,sha256=RGZik93ghfZuRILXf8pfUbPh5VV37_QRAR4FgOMfZqQ,4354
19
- geo_activity_playground/importers/directory.py,sha256=uKqfkp7UmoyL0AiJv8fIrG2qoMuPcVT25ZsAgqno-9U,3704
19
+ geo_activity_playground/importers/directory.py,sha256=8UVgVr20RBf5_Pp7xJfvE-cSbFWWXVFNEzpcE7MdeEo,4331
20
20
  geo_activity_playground/importers/strava_api.py,sha256=yg1SWt_kbyMYFD5uOp7I0uDUtmAOgQvD2Gx3CZZ_ME8,7615
21
- geo_activity_playground/importers/strava_checkout.py,sha256=TYh_FW7L5jv0oIZYmY8UmaNOf0Y6cHCefyqTpjZub7c,7037
21
+ geo_activity_playground/importers/strava_checkout.py,sha256=0lR4aV1yGjIOcmkt4Govrei-mRttbajfXl-yKEgSdLs,7243
22
+ geo_activity_playground/importers/test_directory.py,sha256=ljXokx7q0OgtHvEdHftcQYEmZJUDVv3OOF5opklxdT4,724
22
23
  geo_activity_playground/importers/test_strava_api.py,sha256=4vX7wDr1a9aRh8myxNrIq6RwDBbP8ZeoXXPc10CAbW4,431
23
- geo_activity_playground/webui/activity_controller.py,sha256=5N4LqRtiVDvDBfD_UQRicGZLB3QPkUiahcKcl0nZxzY,6631
24
- geo_activity_playground/webui/app.py,sha256=mfbgyrWpAY0CFgKCi69a8x3pFvQ7BKWXpGhzAC4S2LI,10211
25
- geo_activity_playground/webui/calendar_controller.py,sha256=1y2a8StGyrO2JNfQvktu8ylGhvju-VQesitzftZHihU,2634
24
+ geo_activity_playground/webui/activity_controller.py,sha256=yM7-EPcoKen-e9mt4D7UWo-E7Zk7Y39_-kr2IV7Km6U,12515
25
+ geo_activity_playground/webui/app.py,sha256=759gC8M1vw2zjH4Zt4Pk9KqWd5RVjl6htu7f76vFrS4,11089
26
+ geo_activity_playground/webui/calendar_controller.py,sha256=vn9YNQglbBrQd3IVIU6d2vT3BxGaW0kcRsXLOjuZgqI,2813
26
27
  geo_activity_playground/webui/config_controller.py,sha256=4M8mQc58Hkm-ssfYF1gKRepiAXFIzkZdIMRSbX-aI1U,320
27
28
  geo_activity_playground/webui/eddington_controller.py,sha256=06YLPeXTrCMgI4eNwbhmXQEyVAK-yAk0OT5XGzjHY-Q,2646
28
29
  geo_activity_playground/webui/entry_controller.py,sha256=4rgMvM_1N1p1IxkDIIf20XQnbsNK3h2jdnXgUg58VOU,1904
@@ -45,8 +46,11 @@ geo_activity_playground/webui/static/safari-pinned-tab.svg,sha256=OzoEVGY0igWRXM
45
46
  geo_activity_playground/webui/static/site.webmanifest,sha256=4vYxdPMpwTdB8EmOvHkkYcjZ8Yrci3pOwwY3o_VwACA,440
46
47
  geo_activity_playground/webui/strava_controller.py,sha256=-DZ1Ae-0cWx5tia2dJpGfsBBoIya0QO7IC2qa1-7Q_U,779
47
48
  geo_activity_playground/webui/summary_controller.py,sha256=CN64yUQkhGFC11iiG8_83E3rYFdRixB5Dl_mUhqSo28,1806
48
- geo_activity_playground/webui/templates/activity.html.j2,sha256=9S6bTsLiIuf7ZXHp8Vz4AEDTcKE4ZjpNX0arxb7Ycsc,4032
49
- geo_activity_playground/webui/templates/calendar-month.html.j2,sha256=LVokl95lPlYpUo-5FbDe3n3SES3LE-MABg0BOcdqP7s,1384
49
+ geo_activity_playground/webui/templates/activity-day.html.j2,sha256=LiYe5e_b8t7k0ZS6Lk8E0bpm-I4-kc-SRS-P8UUQBYI,2237
50
+ geo_activity_playground/webui/templates/activity-lines.html.j2,sha256=5gB1aDjRgi_RventenRfC10_FtMT4ch_VuWvA9AMlBY,1121
51
+ geo_activity_playground/webui/templates/activity-name.html.j2,sha256=opHCj_zY3Xz1l3jIXUQvVdxBNi_D9C-Mdnbx2nQqTTQ,2381
52
+ geo_activity_playground/webui/templates/activity.html.j2,sha256=ncj0K1471nRHtbBL9fqhGZ7a9DoJLyqt6ckX8rnhS28,4946
53
+ geo_activity_playground/webui/templates/calendar-month.html.j2,sha256=rV96gOXS0eZU3Dokg8Wb7AJVXJvTPsw61OJoj8lRvt4,1767
50
54
  geo_activity_playground/webui/templates/calendar.html.j2,sha256=x3E1R6KoscVxfcndFePEA855tYz5UoHDSrDbjkhuOOs,1349
51
55
  geo_activity_playground/webui/templates/config.html.j2,sha256=pmec-TqSl5CVznQlyHuC91o18qa0ZQWHXxSBrlV4au4,796
52
56
  geo_activity_playground/webui/templates/eddington.html.j2,sha256=yl75IzWeIkFpwPj8FjTrzJsz_f-qdETPmNnAGLPJuL8,487
@@ -60,9 +64,9 @@ geo_activity_playground/webui/templates/search.html.j2,sha256=lYFe9PzP8gqTenhZuf
60
64
  geo_activity_playground/webui/templates/square-planner.html.j2,sha256=aIB0ql5qW4HXfp0ENksYYOk9vTgBitwyHJX5W7bqkeY,6512
61
65
  geo_activity_playground/webui/templates/strava-connect.html.j2,sha256=vLMqTnTV-DZJ1FHRjpm4OMgbABMwZQvbs8Ru9baKeBg,1111
62
66
  geo_activity_playground/webui/templates/summary.html.j2,sha256=eEwcPOURJ-uT89jeJGZHq_5pSq56_fTC7z-j_m5nQiA,471
63
- geo_activity_playground/webui/tile_controller.py,sha256=kkZvZ4wUdp-HLoHDmhX1IVdCYKsQR_vg9i5mMI9N0R4,745
64
- geo_activity_playground-0.20.0.dist-info/LICENSE,sha256=4RpAwKO8bPkfXH2lnpeUW0eLkNWglyG4lbrLDU_MOwY,1070
65
- geo_activity_playground-0.20.0.dist-info/METADATA,sha256=ymLK0qHTxydFVtgWUQ8oY_FMHdTtO--nQTur7JDd95Q,1615
66
- geo_activity_playground-0.20.0.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
67
- geo_activity_playground-0.20.0.dist-info/entry_points.txt,sha256=pbNlLI6IIZIp7nPYCfAtiSiz2oxJSCl7DODD6SPkLKk,81
68
- geo_activity_playground-0.20.0.dist-info/RECORD,,
67
+ geo_activity_playground/webui/tile_controller.py,sha256=PISh4vKs27b-LxFfTARtr5RAwHFresA1Kw1MDcERSRU,1221
68
+ geo_activity_playground-0.21.0.dist-info/LICENSE,sha256=4RpAwKO8bPkfXH2lnpeUW0eLkNWglyG4lbrLDU_MOwY,1070
69
+ geo_activity_playground-0.21.0.dist-info/METADATA,sha256=lbVR-EIvkZ05oPkGHW3WgXI1Wn8io-ale9X9de6up0s,1573
70
+ geo_activity_playground-0.21.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
71
+ geo_activity_playground-0.21.0.dist-info/entry_points.txt,sha256=pbNlLI6IIZIp7nPYCfAtiSiz2oxJSCl7DODD6SPkLKk,81
72
+ geo_activity_playground-0.21.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.6.1
2
+ Generator: poetry-core 1.8.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any