geo-activity-playground 0.35.1__py3-none-any.whl → 0.36.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.
- geo_activity_playground/core/activities.py +21 -13
- geo_activity_playground/core/raster_map.py +9 -13
- geo_activity_playground/importers/directory.py +6 -15
- geo_activity_playground/webui/app.py +27 -35
- geo_activity_playground/webui/{auth/blueprint.py → auth_blueprint.py} +1 -1
- geo_activity_playground/webui/{eddington/controller.py → eddington_blueprint.py} +17 -13
- geo_activity_playground/webui/heatmap/blueprint.py +36 -10
- geo_activity_playground/webui/heatmap/heatmap_controller.py +142 -60
- geo_activity_playground/webui/heatmap/templates/heatmap/index.html.j2 +30 -12
- geo_activity_playground/webui/{search/blueprint.py → search_blueprint.py} +1 -1
- geo_activity_playground/webui/settings/blueprint.py +1 -2
- geo_activity_playground/webui/square_planner_blueprint.py +118 -0
- geo_activity_playground/webui/{summary/controller.py → summary_blueprint.py} +23 -23
- geo_activity_playground/webui/templates/page.html.j2 +11 -0
- geo_activity_playground/webui/tile_blueprint.py +42 -0
- geo_activity_playground/webui/upload_blueprint.py +1 -3
- {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.0.dist-info}/METADATA +1 -1
- {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.0.dist-info}/RECORD +26 -34
- geo_activity_playground/webui/eddington/__init__.py +0 -0
- geo_activity_playground/webui/eddington/blueprint.py +0 -16
- geo_activity_playground/webui/square_planner/__init__.py +0 -0
- geo_activity_playground/webui/square_planner/blueprint.py +0 -38
- geo_activity_playground/webui/square_planner/controller.py +0 -101
- geo_activity_playground/webui/summary/__init__.py +0 -0
- geo_activity_playground/webui/summary/blueprint.py +0 -14
- geo_activity_playground/webui/tile/__init__.py +0 -0
- geo_activity_playground/webui/tile/blueprint.py +0 -29
- geo_activity_playground/webui/tile/controller.py +0 -36
- /geo_activity_playground/webui/{auth/templates → templates}/auth/index.html.j2 +0 -0
- /geo_activity_playground/webui/{eddington/templates → templates}/eddington/index.html.j2 +0 -0
- /geo_activity_playground/webui/{search/templates → templates}/search/index.html.j2 +0 -0
- /geo_activity_playground/webui/{square_planner/templates → templates}/square_planner/index.html.j2 +0 -0
- /geo_activity_playground/webui/{summary/templates → templates}/summary/index.html.j2 +0 -0
- {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.0.dist-info}/LICENSE +0 -0
- {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.0.dist-info}/WHEEL +0 -0
- {geo_activity_playground-0.35.1.dist-info → geo_activity_playground-0.36.0.dist-info}/entry_points.txt +0 -0
@@ -3,6 +3,7 @@ import functools
|
|
3
3
|
import json
|
4
4
|
import logging
|
5
5
|
import pickle
|
6
|
+
from collections.abc import Callable
|
6
7
|
from typing import Any
|
7
8
|
from typing import Iterator
|
8
9
|
from typing import Optional
|
@@ -180,41 +181,48 @@ def make_geojson_from_time_series(time_series: pd.DataFrame) -> str:
|
|
180
181
|
return geojson.dumps(fc)
|
181
182
|
|
182
183
|
|
183
|
-
def
|
184
|
-
|
185
|
-
|
186
|
-
high = max(speed_without_na)
|
187
|
-
clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
|
184
|
+
def inter_quartile_range(values):
|
185
|
+
return np.quantile(values, 0.75) - np.quantile(values, 0.25)
|
186
|
+
|
188
187
|
|
188
|
+
def make_geojson_color_line(time_series: pd.DataFrame) -> str:
|
189
|
+
low, high, clamp_speed = _make_speed_clamp(time_series["speed"])
|
189
190
|
cmap = matplotlib.colormaps["viridis"]
|
190
191
|
features = [
|
191
192
|
geojson.Feature(
|
192
193
|
geometry=geojson.LineString(
|
193
194
|
coordinates=[
|
194
195
|
[row["longitude"], row["latitude"]],
|
195
|
-
[
|
196
|
+
[next_row["longitude"], next_row["latitude"]],
|
196
197
|
]
|
197
198
|
),
|
198
199
|
properties={
|
199
|
-
"speed":
|
200
|
-
"color": matplotlib.colors.to_hex(cmap(clamp_speed(
|
200
|
+
"speed": next_row["speed"] if np.isfinite(next_row["speed"]) else 0.0,
|
201
|
+
"color": matplotlib.colors.to_hex(cmap(clamp_speed(next_row["speed"]))),
|
201
202
|
},
|
202
203
|
)
|
203
204
|
for _, group in time_series.groupby("segment_id")
|
204
|
-
for (_, row), (_,
|
205
|
+
for (_, row), (_, next_row) in zip(group.iterrows(), group.iloc[1:].iterrows())
|
205
206
|
]
|
206
207
|
feature_collection = geojson.FeatureCollection(features)
|
207
208
|
return geojson.dumps(feature_collection)
|
208
209
|
|
209
210
|
|
210
211
|
def make_speed_color_bar(time_series: pd.DataFrame) -> dict[str, Any]:
|
211
|
-
|
212
|
-
low = min(speed_without_na)
|
213
|
-
high = max(speed_without_na)
|
212
|
+
low, high, clamp_speed = _make_speed_clamp(time_series["speed"])
|
214
213
|
cmap = matplotlib.colormaps["viridis"]
|
215
|
-
clamp_speed = lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
|
216
214
|
colors = [
|
217
215
|
(f"{speed:.1f}", matplotlib.colors.to_hex(cmap(clamp_speed(speed))))
|
218
216
|
for speed in np.linspace(low, high, 10)
|
219
217
|
]
|
220
218
|
return {"low": low, "high": high, "colors": colors}
|
219
|
+
|
220
|
+
|
221
|
+
def _make_speed_clamp(speeds: pd.Series) -> tuple[float, float, Callable]:
|
222
|
+
speed_without_na = speeds.dropna()
|
223
|
+
low = min(speed_without_na)
|
224
|
+
high = min(
|
225
|
+
max(speed_without_na),
|
226
|
+
np.median(speed_without_na) + 1.5 * inter_quartile_range(speed_without_na),
|
227
|
+
)
|
228
|
+
return low, high, lambda speed: min(max((speed - low) / (high - low), 0.0), 1.0)
|
@@ -42,17 +42,17 @@ class GeoBounds:
|
|
42
42
|
@dataclasses.dataclass
|
43
43
|
class TileBounds:
|
44
44
|
zoom: int
|
45
|
-
x1:
|
46
|
-
y1:
|
47
|
-
x2:
|
48
|
-
y2:
|
45
|
+
x1: float
|
46
|
+
y1: float
|
47
|
+
x2: float
|
48
|
+
y2: float
|
49
49
|
|
50
50
|
@property
|
51
|
-
def width(self) ->
|
51
|
+
def width(self) -> float:
|
52
52
|
return self.x2 - self.x1
|
53
53
|
|
54
54
|
@property
|
55
|
-
def height(self) ->
|
55
|
+
def height(self) -> float:
|
56
56
|
return self.y2 - self.y1
|
57
57
|
|
58
58
|
|
@@ -91,12 +91,6 @@ class RasterMapImage:
|
|
91
91
|
## Converter functions ##
|
92
92
|
|
93
93
|
|
94
|
-
def tile_bounds_from_geo_bounds(geo_bounds: GeoBounds) -> TileBounds:
|
95
|
-
x1, y1 = compute_tile_float(geo_bounds.lat_max, geo_bounds.lon_min)
|
96
|
-
x2, y2 = compute_tile_float(geo_bounds.lat_min, geo_bounds.lon_min)
|
97
|
-
return TileBounds(x1, y1, x2, y2)
|
98
|
-
|
99
|
-
|
100
94
|
def pixel_bounds_from_tile_bounds(tile_bounds: TileBounds) -> PixelBounds:
|
101
95
|
return PixelBounds(
|
102
96
|
int(tile_bounds.x1 * OSM_TILE_SIZE),
|
@@ -204,7 +198,9 @@ def map_image_from_tile_bounds(tile_bounds: TileBounds, config: Config) -> np.nd
|
|
204
198
|
north_west = np.array([tile_bounds.x1, tile_bounds.y1])
|
205
199
|
offset = north_west % 1
|
206
200
|
tile_anchor = north_west - offset
|
207
|
-
pixel_anchor = np.array([0, 0]) - np.array(
|
201
|
+
pixel_anchor: np.ndarray = np.array([0, 0]) - np.array(
|
202
|
+
offset * OSM_TILE_SIZE, dtype=np.int64
|
203
|
+
)
|
208
204
|
|
209
205
|
num_tile_x = int(np.ceil(tile_bounds.width)) + 1
|
210
206
|
num_tile_y = int(np.ceil(tile_bounds.height)) + 1
|
@@ -25,7 +25,7 @@ ACTIVITY_DIR = pathlib.Path("Activities")
|
|
25
25
|
|
26
26
|
|
27
27
|
def import_from_directory(
|
28
|
-
metadata_extraction_regexes: list[str],
|
28
|
+
metadata_extraction_regexes: list[str], config: Config
|
29
29
|
) -> None:
|
30
30
|
|
31
31
|
activity_paths = [
|
@@ -63,20 +63,11 @@ def import_from_directory(
|
|
63
63
|
del file_hashes[deleted_file]
|
64
64
|
work_tracker.discard(deleted_file)
|
65
65
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
paths_with_errors.append(errors)
|
72
|
-
else:
|
73
|
-
with multiprocessing.Pool(num_processes) as pool:
|
74
|
-
paths_with_errors = tqdm(
|
75
|
-
pool.imap(_cache_single_file, new_activity_paths),
|
76
|
-
desc="Parse activity metadata (concurrently)",
|
77
|
-
total=len(new_activity_paths),
|
78
|
-
)
|
79
|
-
paths_with_errors = [error for error in paths_with_errors if error]
|
66
|
+
paths_with_errors = []
|
67
|
+
for path in tqdm(new_activity_paths, desc="Parse activity metadata (serially)"):
|
68
|
+
errors = _cache_single_file(path)
|
69
|
+
if errors:
|
70
|
+
paths_with_errors.append(errors)
|
80
71
|
|
81
72
|
for path in tqdm(new_activity_paths, desc="Collate activity metadata"):
|
82
73
|
activity_id = get_file_hash(path)
|
@@ -9,34 +9,29 @@ import urllib.parse
|
|
9
9
|
from flask import Flask
|
10
10
|
from flask import render_template
|
11
11
|
|
12
|
-
from
|
13
|
-
from
|
14
|
-
from
|
15
|
-
from
|
16
|
-
from
|
17
|
-
from
|
18
|
-
from
|
19
|
-
from
|
20
|
-
from
|
21
|
-
from
|
22
|
-
from
|
23
|
-
from
|
24
|
-
from
|
25
|
-
from
|
26
|
-
from
|
27
|
-
from
|
28
|
-
from
|
29
|
-
from
|
30
|
-
from
|
31
|
-
from
|
32
|
-
from
|
33
|
-
|
34
|
-
|
35
|
-
from geo_activity_playground.webui.summary.blueprint import make_summary_blueprint
|
36
|
-
from geo_activity_playground.webui.summary.controller import SummaryController
|
37
|
-
from geo_activity_playground.webui.tile.blueprint import make_tile_blueprint
|
38
|
-
from geo_activity_playground.webui.tile.controller import TileController
|
39
|
-
from geo_activity_playground.webui.upload_blueprint import make_upload_blueprint
|
12
|
+
from ..core.activities import ActivityRepository
|
13
|
+
from ..core.config import Config
|
14
|
+
from ..core.config import ConfigAccessor
|
15
|
+
from ..explorer.tile_visits import TileVisitAccessor
|
16
|
+
from .activity.blueprint import make_activity_blueprint
|
17
|
+
from .activity.controller import ActivityController
|
18
|
+
from .auth_blueprint import make_auth_blueprint
|
19
|
+
from .authenticator import Authenticator
|
20
|
+
from .calendar.blueprint import make_calendar_blueprint
|
21
|
+
from .calendar.controller import CalendarController
|
22
|
+
from .eddington_blueprint import make_eddington_blueprint
|
23
|
+
from .entry_controller import EntryController
|
24
|
+
from .equipment.blueprint import make_equipment_blueprint
|
25
|
+
from .equipment.controller import EquipmentController
|
26
|
+
from .explorer.blueprint import make_explorer_blueprint
|
27
|
+
from .explorer.controller import ExplorerController
|
28
|
+
from .heatmap.blueprint import make_heatmap_blueprint
|
29
|
+
from .search_blueprint import make_search_blueprint
|
30
|
+
from .settings.blueprint import make_settings_blueprint
|
31
|
+
from .square_planner_blueprint import make_square_planner_blueprint
|
32
|
+
from .summary_blueprint import make_summary_blueprint
|
33
|
+
from .tile_blueprint import make_tile_blueprint
|
34
|
+
from .upload_blueprint import make_upload_blueprint
|
40
35
|
|
41
36
|
|
42
37
|
def route_start(app: Flask, repository: ActivityRepository, config: Config) -> None:
|
@@ -87,12 +82,9 @@ def web_ui_main(
|
|
87
82
|
authenticator = Authenticator(config_accessor())
|
88
83
|
|
89
84
|
config = config_accessor()
|
90
|
-
summary_controller = SummaryController(repository, config)
|
91
|
-
tile_controller = TileController(config)
|
92
85
|
activity_controller = ActivityController(repository, tile_visit_accessor, config)
|
93
86
|
calendar_controller = CalendarController(repository)
|
94
87
|
equipment_controller = EquipmentController(repository, config)
|
95
|
-
eddington_controller = EddingtonController(repository)
|
96
88
|
explorer_controller = ExplorerController(
|
97
89
|
repository, tile_visit_accessor, config_accessor
|
98
90
|
)
|
@@ -108,7 +100,7 @@ def web_ui_main(
|
|
108
100
|
make_calendar_blueprint(calendar_controller), url_prefix="/calendar"
|
109
101
|
)
|
110
102
|
app.register_blueprint(
|
111
|
-
make_eddington_blueprint(
|
103
|
+
make_eddington_blueprint(repository), url_prefix="/eddington"
|
112
104
|
)
|
113
105
|
app.register_blueprint(
|
114
106
|
make_equipment_blueprint(equipment_controller), url_prefix="/equipment"
|
@@ -125,7 +117,7 @@ def web_ui_main(
|
|
125
117
|
url_prefix="/settings",
|
126
118
|
)
|
127
119
|
app.register_blueprint(
|
128
|
-
make_square_planner_blueprint(
|
120
|
+
make_square_planner_blueprint(tile_visit_accessor),
|
129
121
|
url_prefix="/square-planner",
|
130
122
|
)
|
131
123
|
app.register_blueprint(
|
@@ -133,10 +125,10 @@ def web_ui_main(
|
|
133
125
|
url_prefix="/search",
|
134
126
|
)
|
135
127
|
app.register_blueprint(
|
136
|
-
make_summary_blueprint(
|
128
|
+
make_summary_blueprint(repository, config), url_prefix="/summary"
|
137
129
|
)
|
138
130
|
|
139
|
-
app.register_blueprint(make_tile_blueprint(
|
131
|
+
app.register_blueprint(make_tile_blueprint(config), url_prefix="/tile")
|
140
132
|
app.register_blueprint(
|
141
133
|
make_upload_blueprint(
|
142
134
|
repository, tile_visit_accessor, config_accessor(), authenticator
|
@@ -4,7 +4,7 @@ from flask import render_template
|
|
4
4
|
from flask import request
|
5
5
|
from flask import url_for
|
6
6
|
|
7
|
-
from
|
7
|
+
from .authenticator import Authenticator
|
8
8
|
|
9
9
|
|
10
10
|
def make_auth_blueprint(authenticator: Authenticator) -> Blueprint:
|
@@ -1,17 +1,19 @@
|
|
1
1
|
import altair as alt
|
2
2
|
import numpy as np
|
3
3
|
import pandas as pd
|
4
|
+
from flask import Blueprint
|
5
|
+
from flask import render_template
|
4
6
|
|
5
|
-
from
|
7
|
+
from geo_activity_playground.core.activities import ActivityRepository
|
6
8
|
|
7
9
|
|
8
|
-
|
9
|
-
|
10
|
-
self._repository = repository
|
10
|
+
def make_eddington_blueprint(repository: ActivityRepository) -> Blueprint:
|
11
|
+
blueprint = Blueprint("eddington", __name__, template_folder="templates")
|
11
12
|
|
12
|
-
|
13
|
-
|
14
|
-
|
13
|
+
@blueprint.route("/")
|
14
|
+
def index():
|
15
|
+
activities = repository.meta.loc[
|
16
|
+
repository.meta["consider_for_achievements"]
|
15
17
|
].copy()
|
16
18
|
activities["day"] = [start.date() for start in activities["start"]]
|
17
19
|
|
@@ -67,11 +69,13 @@ class EddingtonController:
|
|
67
69
|
.interactive()
|
68
70
|
.to_json(format="vega")
|
69
71
|
)
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
72
|
+
return render_template(
|
73
|
+
"eddington/index.html.j2",
|
74
|
+
eddington_number=en,
|
75
|
+
logarithmic_plot=logarithmic_plot,
|
76
|
+
eddington_table=eddington.loc[
|
75
77
|
(eddington["distance_km"] > en) & (eddington["distance_km"] <= en + 10)
|
76
78
|
].to_dict(orient="records"),
|
77
|
-
|
79
|
+
)
|
80
|
+
|
81
|
+
return blueprint
|
@@ -1,12 +1,13 @@
|
|
1
|
+
import dateutil.parser
|
1
2
|
from flask import Blueprint
|
2
3
|
from flask import render_template
|
3
4
|
from flask import request
|
4
5
|
from flask import Response
|
5
6
|
|
6
|
-
from
|
7
|
-
from ...explorer.tile_visits import TileVisitAccessor
|
8
|
-
from .heatmap_controller import HeatmapController
|
7
|
+
from geo_activity_playground.core.activities import ActivityRepository
|
9
8
|
from geo_activity_playground.core.config import Config
|
9
|
+
from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
|
10
|
+
from geo_activity_playground.webui.heatmap.heatmap_controller import HeatmapController
|
10
11
|
|
11
12
|
|
12
13
|
def make_heatmap_blueprint(
|
@@ -21,21 +22,46 @@ def make_heatmap_blueprint(
|
|
21
22
|
def index():
|
22
23
|
return render_template(
|
23
24
|
"heatmap/index.html.j2",
|
24
|
-
**heatmap_controller.render(
|
25
|
+
**heatmap_controller.render(
|
26
|
+
[int(k) for k in request.args.getlist("kind")],
|
27
|
+
request.args.get(
|
28
|
+
"date-start", type=dateutil.parser.parse, default=None
|
29
|
+
),
|
30
|
+
request.args.get("date-end", type=dateutil.parser.parse, default=None),
|
31
|
+
)
|
25
32
|
)
|
26
33
|
|
27
|
-
@blueprint.route("/tile/<z>/<x>/<y
|
28
|
-
def tile(x:
|
34
|
+
@blueprint.route("/tile/<int:z>/<int:x>/<int:y>.png")
|
35
|
+
def tile(x: int, y: int, z: int):
|
29
36
|
return Response(
|
30
|
-
heatmap_controller.render_tile(
|
37
|
+
heatmap_controller.render_tile(
|
38
|
+
x,
|
39
|
+
y,
|
40
|
+
z,
|
41
|
+
[int(k) for k in request.args.getlist("kind")],
|
42
|
+
request.args.get(
|
43
|
+
"date-start", type=dateutil.parser.parse, default=None
|
44
|
+
),
|
45
|
+
request.args.get("date-end", type=dateutil.parser.parse, default=None),
|
46
|
+
),
|
31
47
|
mimetype="image/png",
|
32
48
|
)
|
33
49
|
|
34
|
-
@blueprint.route(
|
35
|
-
|
50
|
+
@blueprint.route(
|
51
|
+
"/download/<float:north>/<float:east>/<float:south>/<float:west>/heatmap.png"
|
52
|
+
)
|
53
|
+
def download(north: float, east: float, south: float, west: float):
|
36
54
|
return Response(
|
37
55
|
heatmap_controller.download_heatmap(
|
38
|
-
|
56
|
+
north,
|
57
|
+
east,
|
58
|
+
south,
|
59
|
+
west,
|
60
|
+
[int(k) for k in request.args.getlist("kind")],
|
61
|
+
request.args.get(
|
62
|
+
"date-start", type=dateutil.parser.parse, default=None
|
63
|
+
),
|
64
|
+
request.args.get("date-end", type=dateutil.parser.parse, default=None),
|
39
65
|
),
|
40
66
|
mimetype="image/png",
|
41
67
|
headers={"Content-disposition": 'attachment; filename="heatmap.png"'},
|
@@ -1,6 +1,8 @@
|
|
1
|
+
import datetime
|
1
2
|
import io
|
2
3
|
import logging
|
3
4
|
import pathlib
|
5
|
+
from typing import Optional
|
4
6
|
|
5
7
|
import matplotlib.pylab as pl
|
6
8
|
import numpy as np
|
@@ -13,6 +15,7 @@ from geo_activity_playground.core.raster_map import convert_to_grayscale
|
|
13
15
|
from geo_activity_playground.core.raster_map import GeoBounds
|
14
16
|
from geo_activity_playground.core.raster_map import get_sensible_zoom_level
|
15
17
|
from geo_activity_playground.core.raster_map import get_tile
|
18
|
+
from geo_activity_playground.core.raster_map import OSM_TILE_SIZE
|
16
19
|
from geo_activity_playground.core.raster_map import PixelBounds
|
17
20
|
from geo_activity_playground.core.tasks import work_tracker
|
18
21
|
from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
|
@@ -25,9 +28,6 @@ from geo_activity_playground.webui.explorer.controller import (
|
|
25
28
|
logger = logging.getLogger(__name__)
|
26
29
|
|
27
30
|
|
28
|
-
OSM_TILE_SIZE = 256 # OSM tile size in pixel
|
29
|
-
|
30
|
-
|
31
31
|
class HeatmapController:
|
32
32
|
def __init__(
|
33
33
|
self,
|
@@ -48,7 +48,12 @@ class HeatmapController:
|
|
48
48
|
"activities_per_tile"
|
49
49
|
]
|
50
50
|
|
51
|
-
def render(
|
51
|
+
def render(
|
52
|
+
self,
|
53
|
+
kinds: list[int],
|
54
|
+
date_start: Optional[datetime.date],
|
55
|
+
date_end: Optional[datetime.date],
|
56
|
+
) -> dict:
|
52
57
|
zoom = 14
|
53
58
|
tiles = self.tile_histories[zoom]
|
54
59
|
medians = tiles.median(skipna=True)
|
@@ -60,9 +65,17 @@ class HeatmapController:
|
|
60
65
|
available_kinds = sorted(self._repository.meta["kind"].unique())
|
61
66
|
|
62
67
|
if not kinds:
|
63
|
-
kinds = available_kinds
|
68
|
+
kinds = list(range(len(available_kinds)))
|
69
|
+
|
70
|
+
extra_args = []
|
71
|
+
if date_start is not None:
|
72
|
+
extra_args.append(f"date-start={date_start.isoformat()}")
|
73
|
+
if date_end is not None:
|
74
|
+
extra_args.append(f"date-end={date_end.isoformat()}")
|
75
|
+
for kind in kinds:
|
76
|
+
extra_args.append(f"kind={kind}")
|
64
77
|
|
65
|
-
|
78
|
+
values = {
|
66
79
|
"center": {
|
67
80
|
"latitude": median_lat,
|
68
81
|
"longitude": median_lon,
|
@@ -76,69 +89,117 @@ class HeatmapController:
|
|
76
89
|
},
|
77
90
|
"kinds": kinds,
|
78
91
|
"available_kinds": available_kinds,
|
79
|
-
"
|
92
|
+
"extra_args": "&".join(extra_args),
|
80
93
|
}
|
94
|
+
if date_start is not None:
|
95
|
+
values["date_start"] = date_start.date().isoformat()
|
96
|
+
if date_end is not None:
|
97
|
+
values["date_end"] = date_end.date().isoformat()
|
81
98
|
|
82
|
-
|
99
|
+
return values
|
100
|
+
|
101
|
+
def _get_counts(
|
102
|
+
self,
|
103
|
+
x: int,
|
104
|
+
y: int,
|
105
|
+
z: int,
|
106
|
+
kind: str,
|
107
|
+
date_start: Optional[datetime.date],
|
108
|
+
date_end: Optional[datetime.date],
|
109
|
+
) -> np.ndarray:
|
83
110
|
tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
|
84
|
-
|
85
|
-
if
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
tile_counts = np.zeros(tile_pixels, dtype=np.int32)
|
94
|
-
else:
|
95
|
-
tile_counts = np.zeros(tile_pixels, dtype=np.int32)
|
96
|
-
tile_count_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
97
|
-
activity_ids = self.activities_per_tile[z].get((x, y), set())
|
98
|
-
activity_ids_kind = set()
|
99
|
-
for activity_id in activity_ids:
|
100
|
-
activity = self._repository.get_activity_by_id(activity_id)
|
101
|
-
if activity["kind"] == kind:
|
102
|
-
activity_ids_kind.add(activity_id)
|
103
|
-
if activity_ids_kind:
|
104
|
-
with work_tracker(
|
105
|
-
tile_count_cache_path.with_suffix(".json")
|
106
|
-
) as parsed_activities:
|
107
|
-
if parsed_activities - activity_ids_kind:
|
111
|
+
tile_counts = np.zeros(tile_pixels, dtype=np.int32)
|
112
|
+
if date_start is None and date_end is None:
|
113
|
+
tile_count_cache_path = pathlib.Path(
|
114
|
+
f"Cache/Heatmap/{kind}/{z}/{x}/{y}.npy"
|
115
|
+
)
|
116
|
+
if tile_count_cache_path.exists():
|
117
|
+
try:
|
118
|
+
tile_counts = np.load(tile_count_cache_path)
|
119
|
+
except ValueError:
|
108
120
|
logger.warning(
|
109
|
-
f"
|
121
|
+
f"Heatmap count file {tile_count_cache_path} is corrupted, deleting."
|
110
122
|
)
|
123
|
+
tile_count_cache_path.unlink()
|
111
124
|
tile_counts = np.zeros(tile_pixels, dtype=np.int32)
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
125
|
+
tile_count_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
126
|
+
activity_ids = self.activities_per_tile[z].get((x, y), set())
|
127
|
+
activity_ids_kind = set()
|
128
|
+
for activity_id in activity_ids:
|
129
|
+
activity = self._repository.get_activity_by_id(activity_id)
|
130
|
+
if activity["kind"] == kind:
|
131
|
+
activity_ids_kind.add(activity_id)
|
132
|
+
if activity_ids_kind:
|
133
|
+
with work_tracker(
|
134
|
+
tile_count_cache_path.with_suffix(".json")
|
135
|
+
) as parsed_activities:
|
136
|
+
if parsed_activities - activity_ids_kind:
|
137
|
+
logger.warning(
|
138
|
+
f"Resetting heatmap cache for {kind=}/{x=}/{y=}/{z=} because activities have been removed."
|
122
139
|
)
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
140
|
+
tile_counts = np.zeros(tile_pixels, dtype=np.int32)
|
141
|
+
parsed_activities.clear()
|
142
|
+
for activity_id in activity_ids_kind:
|
143
|
+
if activity_id in parsed_activities:
|
144
|
+
continue
|
145
|
+
parsed_activities.add(activity_id)
|
146
|
+
time_series = self._repository.get_time_series(activity_id)
|
147
|
+
for _, group in time_series.groupby("segment_id"):
|
148
|
+
xy_pixels = (
|
149
|
+
np.array(
|
150
|
+
[group["x"] * 2**z - x, group["y"] * 2**z - y]
|
151
|
+
).T
|
152
|
+
* OSM_TILE_SIZE
|
153
|
+
)
|
154
|
+
im = Image.new("L", tile_pixels)
|
155
|
+
draw = ImageDraw.Draw(im)
|
156
|
+
pixels = list(map(int, xy_pixels.flatten()))
|
157
|
+
draw.line(pixels, fill=1, width=max(3, 6 * (z - 17)))
|
158
|
+
aim = np.array(im)
|
159
|
+
tile_counts += aim
|
160
|
+
tmp_path = tile_count_cache_path.with_suffix(".tmp.npy")
|
161
|
+
np.save(tmp_path, tile_counts)
|
162
|
+
tile_count_cache_path.unlink(missing_ok=True)
|
163
|
+
tmp_path.rename(tile_count_cache_path)
|
164
|
+
else:
|
165
|
+
activity_ids = self.activities_per_tile[z].get((x, y), set())
|
166
|
+
for activity_id in activity_ids:
|
167
|
+
activity = self._repository.get_activity_by_id(activity_id)
|
168
|
+
if not activity["kind"] == kind:
|
169
|
+
continue
|
170
|
+
if date_start is not None and activity["start"] < date_start:
|
171
|
+
continue
|
172
|
+
if date_end is not None and date_end < activity["start"]:
|
173
|
+
continue
|
174
|
+
time_series = self._repository.get_time_series(activity_id)
|
175
|
+
for _, group in time_series.groupby("segment_id"):
|
176
|
+
xy_pixels = (
|
177
|
+
np.array([group["x"] * 2**z - x, group["y"] * 2**z - y]).T
|
178
|
+
* OSM_TILE_SIZE
|
179
|
+
)
|
180
|
+
im = Image.new("L", tile_pixels)
|
181
|
+
draw = ImageDraw.Draw(im)
|
182
|
+
pixels = list(map(int, xy_pixels.flatten()))
|
183
|
+
draw.line(pixels, fill=1, width=max(3, 6 * (z - 17)))
|
184
|
+
aim = np.array(im)
|
185
|
+
tile_counts += aim
|
133
186
|
return tile_counts
|
134
187
|
|
135
188
|
def _render_tile_image(
|
136
|
-
self,
|
189
|
+
self,
|
190
|
+
x: int,
|
191
|
+
y: int,
|
192
|
+
z: int,
|
193
|
+
kinds_ids: list[int],
|
194
|
+
date_start: Optional[datetime.date],
|
195
|
+
date_end: Optional[datetime.date],
|
137
196
|
) -> np.ndarray:
|
138
197
|
tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
|
139
198
|
tile_counts = np.zeros(tile_pixels)
|
140
|
-
|
141
|
-
|
199
|
+
available_kinds = sorted(self._repository.meta["kind"].unique())
|
200
|
+
for kind_id in kinds_ids:
|
201
|
+
kind = available_kinds[kind_id]
|
202
|
+
tile_counts += self._get_counts(x, y, z, kind, date_start, date_end)
|
142
203
|
|
143
204
|
tile_counts = np.sqrt(tile_counts) / 5
|
144
205
|
tile_counts[tile_counts > 1.0] = 1.0
|
@@ -156,13 +217,32 @@ class HeatmapController:
|
|
156
217
|
] + data_color[:, :, c]
|
157
218
|
return map_tile
|
158
219
|
|
159
|
-
def render_tile(
|
220
|
+
def render_tile(
|
221
|
+
self,
|
222
|
+
x: int,
|
223
|
+
y: int,
|
224
|
+
z: int,
|
225
|
+
kind_ids: list[int],
|
226
|
+
date_start: Optional[datetime.date],
|
227
|
+
date_end: Optional[datetime.date],
|
228
|
+
) -> bytes:
|
160
229
|
f = io.BytesIO()
|
161
|
-
pl.imsave(
|
230
|
+
pl.imsave(
|
231
|
+
f,
|
232
|
+
self._render_tile_image(x, y, z, kind_ids, date_start, date_end),
|
233
|
+
format="png",
|
234
|
+
)
|
162
235
|
return bytes(f.getbuffer())
|
163
236
|
|
164
237
|
def download_heatmap(
|
165
|
-
self,
|
238
|
+
self,
|
239
|
+
north: float,
|
240
|
+
east: float,
|
241
|
+
south: float,
|
242
|
+
west: float,
|
243
|
+
kind_ids: list[int],
|
244
|
+
date_start: Optional[datetime.date],
|
245
|
+
date_end: Optional[datetime.date],
|
166
246
|
) -> bytes:
|
167
247
|
geo_bounds = GeoBounds(south, west, north, east)
|
168
248
|
tile_bounds = get_sensible_zoom_level(geo_bounds, (4000, 4000))
|
@@ -185,7 +265,9 @@ class HeatmapController:
|
|
185
265
|
i * OSM_TILE_SIZE : (i + 1) * OSM_TILE_SIZE,
|
186
266
|
j * OSM_TILE_SIZE : (j + 1) * OSM_TILE_SIZE,
|
187
267
|
:,
|
188
|
-
] = self._render_tile_image(
|
268
|
+
] = self._render_tile_image(
|
269
|
+
x, y, tile_bounds.zoom, kind_ids, date_start, date_end
|
270
|
+
)
|
189
271
|
|
190
272
|
f = io.BytesIO()
|
191
273
|
pl.imsave(f, background, format="png")
|