geo-activity-playground 0.45.0__py3-none-any.whl → 1.1.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 (75) hide show
  1. geo_activity_playground/alembic/versions/dc8073871da7_add_plotspec_group_by.py +28 -0
  2. geo_activity_playground/core/config.py +1 -0
  3. geo_activity_playground/core/datamodel.py +12 -0
  4. geo_activity_playground/core/export.py +129 -0
  5. geo_activity_playground/core/meta_search.py +1 -1
  6. geo_activity_playground/core/parametric_plot.py +101 -47
  7. geo_activity_playground/webui/app.py +10 -1
  8. geo_activity_playground/webui/authenticator.py +4 -2
  9. geo_activity_playground/webui/blueprints/activity_blueprint.py +11 -10
  10. geo_activity_playground/webui/blueprints/auth_blueprint.py +6 -2
  11. geo_activity_playground/webui/blueprints/bubble_chart_blueprint.py +2 -1
  12. geo_activity_playground/webui/blueprints/calendar_blueprint.py +3 -2
  13. geo_activity_playground/webui/blueprints/eddington_blueprints.py +3 -2
  14. geo_activity_playground/webui/blueprints/entry_views.py +11 -11
  15. geo_activity_playground/webui/blueprints/equipment_blueprint.py +2 -1
  16. geo_activity_playground/webui/blueprints/explorer_blueprint.py +343 -197
  17. geo_activity_playground/webui/blueprints/export_blueprint.py +31 -0
  18. geo_activity_playground/webui/blueprints/hall_of_fame_blueprint.py +79 -0
  19. geo_activity_playground/webui/blueprints/plot_builder_blueprint.py +38 -19
  20. geo_activity_playground/webui/blueprints/summary_blueprint.py +114 -240
  21. geo_activity_playground/webui/blueprints/upload_blueprint.py +9 -0
  22. geo_activity_playground/webui/columns.py +40 -7
  23. geo_activity_playground/webui/static/{browserconfig.xml → favicons/browserconfig.xml} +1 -1
  24. geo_activity_playground/webui/static/{site.webmanifest → favicons/site.webmanifest} +2 -2
  25. geo_activity_playground/webui/static/server-side-explorer.js +60 -0
  26. geo_activity_playground/webui/templates/activity/name.html.j2 +4 -4
  27. geo_activity_playground/webui/templates/activity/show.html.j2 +8 -8
  28. geo_activity_playground/webui/templates/auth/index.html.j2 +1 -0
  29. geo_activity_playground/webui/templates/eddington/distance.html.j2 +3 -3
  30. geo_activity_playground/webui/templates/eddington/elevation_gain.html.j2 +3 -3
  31. geo_activity_playground/webui/templates/elevation_eddington/index.html.j2 +3 -3
  32. geo_activity_playground/webui/templates/equipment/index.html.j2 +1 -1
  33. geo_activity_playground/webui/templates/explorer/server-side.html.j2 +42 -36
  34. geo_activity_playground/webui/templates/export/index.html.j2 +39 -0
  35. geo_activity_playground/webui/templates/hall_of_fame/index.html.j2 +58 -0
  36. geo_activity_playground/webui/templates/home.html.j2 +1 -4
  37. geo_activity_playground/webui/templates/page.html.j2 +26 -43
  38. geo_activity_playground/webui/templates/plot-macros.html.j2 +72 -0
  39. geo_activity_playground/webui/templates/plot_builder/edit.html.j2 +12 -7
  40. geo_activity_playground/webui/templates/plot_builder/import-spec.html.j2 +24 -0
  41. geo_activity_playground/webui/templates/plot_builder/index.html.j2 +5 -0
  42. geo_activity_playground/webui/templates/summary/index.html.j2 +23 -230
  43. geo_activity_playground/webui/templates/summary/vega-chart.html.j2 +3 -0
  44. {geo_activity_playground-0.45.0.dist-info → geo_activity_playground-1.1.0.dist-info}/METADATA +2 -1
  45. {geo_activity_playground-0.45.0.dist-info → geo_activity_playground-1.1.0.dist-info}/RECORD +74 -65
  46. geo_activity_playground/webui/templates/explorer/index.html.j2 +0 -148
  47. /geo_activity_playground/webui/static/{bootstrap-dark-mode.js → bootstrap/bootstrap-dark-mode.js} +0 -0
  48. /geo_activity_playground/webui/static/{bootstrap.bundle.min.js → bootstrap/bootstrap.bundle.min.js} +0 -0
  49. /geo_activity_playground/webui/static/{bootstrap.min.css → bootstrap/bootstrap.min.css} +0 -0
  50. /geo_activity_playground/webui/static/{android-chrome-192x192.png → favicons/android-chrome-192x192.png} +0 -0
  51. /geo_activity_playground/webui/static/{android-chrome-512x512.png → favicons/android-chrome-512x512.png} +0 -0
  52. /geo_activity_playground/webui/static/{apple-touch-icon.png → favicons/apple-touch-icon.png} +0 -0
  53. /geo_activity_playground/webui/static/{favicon-16x16.png → favicons/favicon-16x16.png} +0 -0
  54. /geo_activity_playground/webui/static/{favicon-32x32.png → favicons/favicon-32x32.png} +0 -0
  55. /geo_activity_playground/webui/static/{favicon-48x48.png → favicons/favicon-48x48.png} +0 -0
  56. /geo_activity_playground/webui/static/{favicon.ico → favicons/favicon.ico} +0 -0
  57. /geo_activity_playground/webui/static/{favicon.svg → favicons/favicon.svg} +0 -0
  58. /geo_activity_playground/webui/static/{mstile-150x150.png → favicons/mstile-150x150.png} +0 -0
  59. /geo_activity_playground/webui/static/{web-app-manifest-192x192.png → favicons/web-app-manifest-192x192.png} +0 -0
  60. /geo_activity_playground/webui/static/{web-app-manifest-512x512.png → favicons/web-app-manifest-512x512.png} +0 -0
  61. /geo_activity_playground/webui/static/{Leaflet.fullscreen.min.js → leaflet/Leaflet.fullscreen.min.js} +0 -0
  62. /geo_activity_playground/webui/static/{MarkerCluster.Default.css → leaflet/MarkerCluster.Default.css} +0 -0
  63. /geo_activity_playground/webui/static/{MarkerCluster.css → leaflet/MarkerCluster.css} +0 -0
  64. /geo_activity_playground/webui/static/{fullscreen.png → leaflet/fullscreen.png} +0 -0
  65. /geo_activity_playground/webui/static/{fullscreen@2x.png → leaflet/fullscreen@2x.png} +0 -0
  66. /geo_activity_playground/webui/static/{leaflet.css → leaflet/leaflet.css} +0 -0
  67. /geo_activity_playground/webui/static/{leaflet.fullscreen.css → leaflet/leaflet.fullscreen.css} +0 -0
  68. /geo_activity_playground/webui/static/{leaflet.js → leaflet/leaflet.js} +0 -0
  69. /geo_activity_playground/webui/static/{leaflet.markercluster.js → leaflet/leaflet.markercluster.js} +0 -0
  70. /geo_activity_playground/webui/static/{vega-embed@6 → vega/vega-embed@6.js} +0 -0
  71. /geo_activity_playground/webui/static/{vega-lite@4 → vega/vega-lite@4.js} +0 -0
  72. /geo_activity_playground/webui/static/{vega@5 → vega/vega@5.js} +0 -0
  73. {geo_activity_playground-0.45.0.dist-info → geo_activity_playground-1.1.0.dist-info}/LICENSE +0 -0
  74. {geo_activity_playground-0.45.0.dist-info → geo_activity_playground-1.1.0.dist-info}/WHEEL +0 -0
  75. {geo_activity_playground-0.45.0.dist-info → geo_activity_playground-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,11 @@
1
+ import abc
1
2
  import datetime
3
+ import hashlib
2
4
  import io
3
5
  import itertools
4
6
  import logging
7
+ from collections.abc import Iterable
8
+ from typing import Union
5
9
 
6
10
  import altair as alt
7
11
  import geojson
@@ -12,10 +16,13 @@ import pandas as pd
12
16
  import sqlalchemy
13
17
  from flask import Blueprint
14
18
  from flask import flash
19
+ from flask import json
15
20
  from flask import redirect
16
21
  from flask import render_template
22
+ from flask import request
17
23
  from flask import Response
18
24
  from flask import url_for
25
+ from flask.typing import ResponseReturnValue
19
26
 
20
27
  from ...core.activities import ActivityRepository
21
28
  from ...core.config import ConfigAccessor
@@ -43,6 +50,105 @@ alt.data_transformers.enable("vegafusion")
43
50
  logger = logging.getLogger(__name__)
44
51
 
45
52
 
53
+ def blend_color(
54
+ base: np.ndarray, addition: Union[np.ndarray, float], opacity: float
55
+ ) -> np.ndarray:
56
+ return (1 - opacity) * base + opacity * addition
57
+
58
+
59
+ class ColorStrategy(abc.ABC):
60
+ @abc.abstractmethod
61
+ def color_image(
62
+ self, tile_xy: tuple[int, int], grayscale: np.ndarray
63
+ ) -> np.ndarray:
64
+ pass
65
+
66
+
67
+ class MaxClusterColorStrategy(ColorStrategy):
68
+ def __init__(self, evolution_state, tile_visits):
69
+ self.evolution_state = evolution_state
70
+ self.tile_visits = tile_visits
71
+ self.max_cluster_members = max(
72
+ evolution_state.clusters.values(),
73
+ key=len,
74
+ )
75
+
76
+ def color_image(
77
+ self, tile_xy: tuple[int, int], grayscale: np.ndarray
78
+ ) -> np.ndarray:
79
+ if tile_xy in self.max_cluster_members:
80
+ return blend_color(grayscale, np.array([[[55, 126, 184]]]) / 256, 0.3)
81
+ elif tile_xy in self.evolution_state.memberships:
82
+ return blend_color(grayscale, np.array([[[77, 175, 74]]]) / 256, 0.3)
83
+ elif tile_xy in self.tile_visits:
84
+ return blend_color(grayscale, 0.0, 0.3)
85
+ else:
86
+ return grayscale
87
+
88
+
89
+ class ColorfulClusterColorStrategy(ColorStrategy):
90
+ def __init__(self, evolution_state: TileEvolutionState, tile_visits):
91
+ self.evolution_state = evolution_state
92
+ self.tile_visits = tile_visits
93
+ self.max_cluster_members = max(
94
+ evolution_state.clusters.values(),
95
+ key=len,
96
+ )
97
+ self._cmap = matplotlib.colormaps["hsv"]
98
+
99
+ def color_image(
100
+ self, tile_xy: tuple[int, int], grayscale: np.ndarray
101
+ ) -> np.ndarray:
102
+ if tile_xy in self.evolution_state.memberships:
103
+ cluster_id = self.evolution_state.memberships[tile_xy]
104
+ m = hashlib.sha256()
105
+ m.update(str(cluster_id).encode())
106
+ d = int(m.hexdigest(), base=16) / (256.0**m.digest_size)
107
+ return blend_color(grayscale, np.array([[self._cmap(d)[:3]]]), 0.3)
108
+ elif tile_xy in self.tile_visits:
109
+ return blend_color(grayscale, 0.0, 0.3)
110
+ else:
111
+ return grayscale
112
+
113
+
114
+ class VisitTimeColorStrategy(ColorStrategy):
115
+ def __init__(self, tile_visits, use_first=True):
116
+ self.tile_visits = tile_visits
117
+ self.use_first = use_first
118
+
119
+ def color_image(
120
+ self, tile_xy: tuple[int, int], grayscale: np.ndarray
121
+ ) -> np.ndarray:
122
+ if tile_xy in self.tile_visits:
123
+ today = datetime.date.today()
124
+ cmap = matplotlib.colormaps["plasma"]
125
+ tile_info = self.tile_visits[tile_xy]
126
+ relevant_time = (
127
+ tile_info["first_time"] if self.use_first else tile_info["last_time"]
128
+ )
129
+ last_age_days = (today - relevant_time.date()).days
130
+ color = cmap(max(1 - last_age_days / (2 * 365), 0.0))
131
+ return blend_color(grayscale, np.array([[color[:3]]]), 0.3)
132
+ else:
133
+ return grayscale
134
+
135
+
136
+ class NumVisitsColorStrategy(ColorStrategy):
137
+ def __init__(self, tile_visits):
138
+ self.tile_visits = tile_visits
139
+
140
+ def color_image(
141
+ self, tile_xy: tuple[int, int], grayscale: np.ndarray
142
+ ) -> np.ndarray:
143
+ if tile_xy in self.tile_visits:
144
+ cmap = matplotlib.colormaps["viridis"]
145
+ tile_info = self.tile_visits[tile_xy]
146
+ color = cmap(min(len(tile_info["activity_ids"]) / 50, 1.0))
147
+ return blend_color(grayscale, np.array([[color[:3]]]), 0.3)
148
+ else:
149
+ return grayscale
150
+
151
+
46
152
  def make_explorer_blueprint(
47
153
  authenticator: Authenticator,
48
154
  tile_visit_accessor: TileVisitAccessor,
@@ -52,51 +158,9 @@ def make_explorer_blueprint(
52
158
  ) -> Blueprint:
53
159
  blueprint = Blueprint("explorer", __name__, template_folder="templates")
54
160
 
55
- @blueprint.route("/<int:zoom>")
56
- def map(zoom: int):
57
- if zoom not in config_accessor().explorer_zoom_levels:
58
- return {"zoom_level_not_generated": zoom}
59
-
60
- tile_evolution_states = tile_visit_accessor.tile_state["evolution_state"]
61
- tile_visits = tile_visit_accessor.tile_state["tile_visits"]
62
- tile_histories = tile_visit_accessor.tile_state["tile_history"]
63
-
64
- medians = tile_histories[zoom].median()
65
- median_lat, median_lon = get_tile_upper_left_lat_lon(
66
- medians["tile_x"], medians["tile_y"], zoom
67
- )
68
-
69
- explored = get_three_color_tiles(
70
- tile_visits[zoom], tile_evolution_states[zoom], zoom
71
- )
72
-
73
- context = {
74
- "center": {
75
- "latitude": median_lat,
76
- "longitude": median_lon,
77
- "bbox": (
78
- bounding_box_for_biggest_cluster(
79
- tile_evolution_states[zoom].clusters.values(), zoom
80
- )
81
- if len(tile_evolution_states[zoom].memberships) > 0
82
- else {}
83
- ),
84
- },
85
- "explored": explored,
86
- "plot_tile_evolution": plot_tile_evolution(tile_histories[zoom]),
87
- "plot_cluster_evolution": plot_cluster_evolution(
88
- tile_evolution_states[zoom].cluster_evolution
89
- ),
90
- "plot_square_evolution": plot_square_evolution(
91
- tile_evolution_states[zoom].square_evolution
92
- ),
93
- "zoom": zoom,
94
- }
95
- return render_template("explorer/index.html.j2", **context)
96
-
97
161
  @blueprint.route("/enable-zoom-level/<int:zoom>")
98
162
  @needs_authentication(authenticator)
99
- def enable_zoom_level(zoom: int):
163
+ def enable_zoom_level(zoom: int) -> ResponseReturnValue:
100
164
  if 0 <= zoom <= 19:
101
165
  config_accessor().explorer_zoom_levels.append(zoom)
102
166
  config_accessor().explorer_zoom_levels.sort()
@@ -108,11 +172,11 @@ def make_explorer_blueprint(
108
172
  return redirect(url_for(".map", zoom=zoom))
109
173
 
110
174
  @blueprint.route(
111
- "/<int:zoom>/<float:north>/<float:east>/<float:south>/<float:west>/explored.<suffix>"
175
+ "/<int:zoom>/<float:north>/<float:east>/<float:south>/<float:west>/missing.<suffix>"
112
176
  )
113
- def download(
177
+ def download_missing(
114
178
  zoom: int, north: float, east: float, south: float, west: float, suffix: str
115
- ):
179
+ ) -> ResponseReturnValue:
116
180
  x1, y1 = compute_tile(north, west, zoom)
117
181
  x2, y2 = compute_tile(south, east, zoom)
118
182
  tile_bounds = Bounds(x1, y1, x2 + 2, y2 + 2)
@@ -133,11 +197,11 @@ def make_explorer_blueprint(
133
197
  )
134
198
 
135
199
  @blueprint.route(
136
- "/<int:zoom>/<float:north>/<float:east>/<float:south>/<float:west>/missing.<suffix>"
200
+ "/<int:zoom>/<float:north>/<float:east>/<float:south>/<float:west>/explored.<suffix>"
137
201
  )
138
- def missing(
202
+ def download_explored(
139
203
  zoom: int, north: float, east: float, south: float, west: float, suffix: str
140
- ):
204
+ ) -> ResponseReturnValue:
141
205
  x1, y1 = compute_tile(north, west, zoom)
142
206
  x2, y2 = compute_tile(south, east, zoom)
143
207
  tile_bounds = Bounds(x1, y1, x2 + 2, y2 + 2)
@@ -160,14 +224,14 @@ def make_explorer_blueprint(
160
224
  )
161
225
 
162
226
  @blueprint.route("/<int:zoom>/server-side")
163
- def server_side(zoom: int):
227
+ def server_side(zoom: int) -> ResponseReturnValue:
164
228
  if zoom not in config_accessor().explorer_zoom_levels:
165
229
  return {"zoom_level_not_generated": zoom}
166
230
 
167
- tile_evolution_states = tile_visit_accessor.tile_state["evolution_state"]
168
- tile_histories = tile_visit_accessor.tile_state["tile_history"]
231
+ tile_evolution_state = tile_visit_accessor.tile_state["evolution_state"][zoom]
232
+ tile_history = tile_visit_accessor.tile_state["tile_history"][zoom]
169
233
 
170
- medians = tile_histories[zoom].median()
234
+ medians = tile_history.median()
171
235
  median_lat, median_lon = get_tile_upper_left_lat_lon(
172
236
  medians["tile_x"], medians["tile_y"], zoom
173
237
  )
@@ -178,182 +242,264 @@ def make_explorer_blueprint(
178
242
  "longitude": median_lon,
179
243
  "bbox": (
180
244
  bounding_box_for_biggest_cluster(
181
- tile_evolution_states[zoom].clusters.values(), zoom
245
+ tile_evolution_state.clusters.values(), zoom
182
246
  )
183
- if len(tile_evolution_states[zoom].memberships) > 0
247
+ if len(tile_evolution_state.memberships) > 0
184
248
  else {}
185
249
  ),
186
250
  },
187
- "plot_tile_evolution": plot_tile_evolution(tile_histories[zoom]),
251
+ "plot_tile_evolution": plot_tile_evolution(tile_history),
188
252
  "plot_cluster_evolution": plot_cluster_evolution(
189
- tile_evolution_states[zoom].cluster_evolution
253
+ tile_evolution_state.cluster_evolution
190
254
  ),
191
255
  "plot_square_evolution": plot_square_evolution(
192
- tile_evolution_states[zoom].square_evolution
256
+ tile_evolution_state.square_evolution
193
257
  ),
194
258
  "zoom": zoom,
259
+ "num_tiles": len(tile_history),
260
+ "num_cluster_tiles": len(tile_evolution_state.memberships),
261
+ "square_x": tile_evolution_state.square_x,
262
+ "square_y": tile_evolution_state.square_y,
263
+ "square_size": tile_evolution_state.max_square_size,
264
+ "max_cluster_size": max(map(len, tile_evolution_state.clusters.values())),
195
265
  }
196
266
  return render_template("explorer/server-side.html.j2", **context)
197
267
 
198
268
  @blueprint.route("/<int:zoom>/tile/<int:z>/<int:x>/<int:y>.png")
199
- def tile(zoom: int, z: int, x: int, y: int) -> Response:
269
+ def tile(zoom: int, z: int, x: int, y: int) -> ResponseReturnValue:
200
270
  tile_visits = tile_visit_accessor.tile_state["tile_visits"][zoom]
271
+ evolution_state = tile_visit_accessor.tile_state["evolution_state"][zoom]
201
272
 
202
273
  map_tile = np.array(tile_getter.get_tile(z, x, y)) / 255
274
+ grayscale = image_transforms["grayscale"].transform_image(map_tile)
275
+ square_line_width = 3
276
+ square_color = np.array([[[228, 26, 28]]]) / 256
277
+
278
+ color_strategy_name = request.args.get("color_strategy", "colorful_cluster")
279
+ if color_strategy_name == "default":
280
+ color_strategy_name = config_accessor().cluster_color_strategy
281
+ match color_strategy_name:
282
+ case "max_cluster":
283
+ color_strategy = MaxClusterColorStrategy(evolution_state, tile_visits)
284
+ case "colorful_cluster":
285
+ color_strategy = ColorfulClusterColorStrategy(
286
+ evolution_state, tile_visits
287
+ )
288
+ case "first":
289
+ color_strategy = VisitTimeColorStrategy(tile_visits, use_first=True)
290
+ case "last":
291
+ color_strategy = VisitTimeColorStrategy(tile_visits, use_first=False)
292
+ case "visits":
293
+ color_strategy = NumVisitsColorStrategy(tile_visits)
294
+ case _:
295
+ raise ValueError("Unsupported color strategy.")
296
+
203
297
  if z >= zoom:
204
298
  factor = 2 ** (z - zoom)
205
- if (x // factor, y // factor) in tile_visits:
206
- map_tile = image_transforms["color"].transform_image(map_tile)
207
- else:
208
- map_tile = image_transforms["color"].transform_image(map_tile) / 1.2
299
+ tile_x = x // factor
300
+ tile_y = y // factor
301
+ tile_xy = (tile_x, tile_y)
302
+ result = color_strategy.color_image(tile_xy, grayscale)
303
+
304
+ if x % factor == 0:
305
+ result[:, 0, :] = 0.5
306
+ if y % factor == 0:
307
+ result[0, :, :] = 0.5
308
+
309
+ if (
310
+ evolution_state.square_x is not None
311
+ and evolution_state.square_y is not None
312
+ ):
313
+ if (
314
+ x % factor == 0
315
+ and tile_x == evolution_state.square_x
316
+ and evolution_state.square_y
317
+ <= tile_y
318
+ < evolution_state.square_y + evolution_state.max_square_size
319
+ ):
320
+ result[:, 0:square_line_width] = blend_color(
321
+ result[:, 0:square_line_width], square_color, 0.5
322
+ )
323
+ if (
324
+ y % factor == 0
325
+ and tile_y == evolution_state.square_y
326
+ and evolution_state.square_x
327
+ <= tile_x
328
+ < evolution_state.square_x + evolution_state.max_square_size
329
+ ):
330
+ result[0:square_line_width, :] = blend_color(
331
+ result[0:square_line_width, :], square_color, 0.5
332
+ )
333
+
334
+ if (
335
+ (x + 1) % factor == 0
336
+ and (x + 1) // factor
337
+ == evolution_state.square_x + evolution_state.max_square_size
338
+ and evolution_state.square_y
339
+ <= tile_y
340
+ < evolution_state.square_y + evolution_state.max_square_size
341
+ ):
342
+ result[:, -square_line_width:] = blend_color(
343
+ result[:, -square_line_width:], square_color, 0.5
344
+ )
345
+ if (
346
+ (y + 1) % factor == 0
347
+ and (y + 1) // factor
348
+ == evolution_state.square_y + evolution_state.max_square_size
349
+ and evolution_state.square_x
350
+ <= tile_x
351
+ < evolution_state.square_x + evolution_state.max_square_size
352
+ ):
353
+ result[-square_line_width:, :] = blend_color(
354
+ result[-square_line_width:, :], square_color, 0.5
355
+ )
209
356
  else:
210
- grayscale = image_transforms["color"].transform_image(map_tile) / 1.2
357
+ result = grayscale
211
358
  factor = 2 ** (zoom - z)
212
359
  width = 256 // factor
213
360
  for xo in range(factor):
214
361
  for yo in range(factor):
215
- tile = (x * factor + xo, y * factor + yo)
216
- if tile not in tile_visits:
217
- map_tile[
362
+ tile_x = x * factor + xo
363
+ tile_y = y * factor + yo
364
+ tile_xy = (tile_x, tile_y)
365
+ if tile_xy in tile_visits:
366
+ result[
218
367
  yo * width : (yo + 1) * width, xo * width : (xo + 1) * width
219
- ] = grayscale[
220
- yo * width : (yo + 1) * width, xo * width : (xo + 1) * width
221
- ]
368
+ ] = color_strategy.color_image(
369
+ tile_xy,
370
+ grayscale[
371
+ yo * width : (yo + 1) * width,
372
+ xo * width : (xo + 1) * width,
373
+ ],
374
+ )
375
+
376
+ if (
377
+ evolution_state.square_x is not None
378
+ and evolution_state.square_y is not None
379
+ ):
380
+ if (
381
+ tile_x == evolution_state.square_x
382
+ and evolution_state.square_y
383
+ <= tile_y
384
+ < evolution_state.square_y
385
+ + evolution_state.max_square_size
386
+ ):
387
+ result[
388
+ yo * width : (yo + 1) * width,
389
+ xo * width : xo * width + square_line_width,
390
+ ] = blend_color(
391
+ result[
392
+ yo * width : (yo + 1) * width,
393
+ xo * width : xo * width + square_line_width,
394
+ ],
395
+ square_color,
396
+ 0.5,
397
+ )
398
+ if (
399
+ tile_y == evolution_state.square_y
400
+ and evolution_state.square_x
401
+ <= tile_x
402
+ < evolution_state.square_x
403
+ + evolution_state.max_square_size
404
+ ):
405
+ result[
406
+ yo * width : yo * width + square_line_width,
407
+ xo * width : (xo + 1) * width,
408
+ ] = blend_color(
409
+ result[
410
+ yo * width : yo * width + square_line_width,
411
+ xo * width : (xo + 1) * width,
412
+ ],
413
+ square_color,
414
+ 0.5,
415
+ )
416
+
417
+ if (
418
+ tile_x + 1
419
+ == evolution_state.square_x
420
+ + evolution_state.max_square_size
421
+ and evolution_state.square_y
422
+ <= tile_y
423
+ < evolution_state.square_y
424
+ + evolution_state.max_square_size
425
+ ):
426
+ result[
427
+ yo * width : (yo + 1) * width,
428
+ (xo + 1) * width
429
+ - square_line_width : (xo + 1) * width,
430
+ ] = blend_color(
431
+ result[
432
+ yo * width : (yo + 1) * width,
433
+ (xo + 1) * width
434
+ - square_line_width : (xo + 1) * width,
435
+ ],
436
+ square_color,
437
+ 0.5,
438
+ )
439
+
440
+ if (
441
+ tile_y + 1
442
+ == evolution_state.square_y
443
+ + evolution_state.max_square_size
444
+ and evolution_state.square_x
445
+ <= tile_x
446
+ < evolution_state.square_x
447
+ + evolution_state.max_square_size
448
+ ):
449
+ result[
450
+ (yo + 1) * width
451
+ - square_line_width : (yo + 1) * width,
452
+ xo * width : (xo + 1) * width,
453
+ ] = blend_color(
454
+ result[
455
+ (yo + 1) * width
456
+ - square_line_width : (yo + 1) * width,
457
+ xo * width : (xo + 1) * width,
458
+ ],
459
+ square_color,
460
+ 0.5,
461
+ )
462
+ if width >= 64:
463
+ result[yo * width, :, :] = 0.5
464
+ result[:, xo * width, :] = 0.5
222
465
  f = io.BytesIO()
223
- pl.imsave(f, map_tile, format="png")
466
+ pl.imsave(f, result, format="png")
224
467
  return Response(bytes(f.getbuffer()), mimetype="image/png")
225
468
 
226
- return blueprint
227
-
228
-
229
- def get_three_color_tiles(
230
- tile_visits: dict,
231
- cluster_state: TileEvolutionState,
232
- zoom: int,
233
- ) -> str:
234
- logger.info("Generate data for explorer tile map …")
235
- today = datetime.date.today()
236
- cmap_first = matplotlib.colormaps["plasma"]
237
- cmap_last = matplotlib.colormaps["plasma"]
238
- tile_dict = {}
239
- for tile, tile_data in tile_visits.items():
240
- if not pd.isna(tile_data["first_time"]):
241
- first_age_days = (today - tile_data["first_time"].date()).days
242
- last_age_days = (today - tile_data["last_time"].date()).days
469
+ @blueprint.route("/<int:zoom>/info/<float:latitude>/<float:longitude>")
470
+ def info(zoom: int, latitude: float, longitude: float) -> dict:
471
+ tile_visits = tile_visit_accessor.tile_state["tile_visits"][zoom]
472
+ evolution_state = tile_visit_accessor.tile_state["evolution_state"][zoom]
473
+ tile_xy = compute_tile(latitude, longitude, zoom)
474
+ if tile_xy in tile_visits:
475
+ tile_info = tile_visits[tile_xy]
476
+ first = DB.session.get_one(Activity, tile_info["first_id"])
477
+ last = DB.session.get_one(Activity, tile_info["last_id"])
478
+ result = {
479
+ "tile_xy": f"{tile_xy}",
480
+ "num_visits": len(tile_info["activity_ids"]),
481
+ "first_activity_id": first.id,
482
+ "first_activity_name": first.name,
483
+ "first_time": tile_info["first_time"].isoformat(),
484
+ "last_activity_id": last.id,
485
+ "last_activity_name": last.name,
486
+ "last_time": tile_info["last_time"].isoformat(),
487
+ "is_cluster": tile_xy in evolution_state.memberships,
488
+ "this_cluster_size": len(
489
+ evolution_state.clusters.get(
490
+ evolution_state.memberships.get(tile_xy, None), []
491
+ )
492
+ ),
493
+ }
243
494
  else:
244
- first_age_days = 10000
245
- last_age_days = 10000
246
- tile_dict[tile] = {
247
- "first_activity_id": str(tile_data["first_id"]),
248
- "first_activity_name": DB.session.scalar(
249
- sqlalchemy.select(Activity.name).where(
250
- Activity.id == tile_data["first_id"]
251
- )
252
- ),
253
- "last_activity_id": str(tile_data["last_id"]),
254
- "last_activity_name": DB.session.scalar(
255
- sqlalchemy.select(Activity.name).where(
256
- Activity.id == tile_data["last_id"]
257
- )
258
- ),
259
- "first_age_days": first_age_days,
260
- "first_age_color": matplotlib.colors.to_hex(
261
- cmap_first(max(1 - first_age_days / (2 * 365), 0.0))
262
- ),
263
- "last_age_days": last_age_days,
264
- "last_age_color": matplotlib.colors.to_hex(
265
- cmap_last(max(1 - last_age_days / (2 * 365), 0.0))
266
- ),
267
- "cluster": False,
268
- "color": "#303030",
269
- "first_visit": tile_data["first_time"].date().isoformat(),
270
- "last_visit": tile_data["last_time"].date().isoformat(),
271
- "num_visits": len(tile_data["activity_ids"]),
272
- "square": False,
273
- "tile": f"({zoom}, {tile[0]}, {tile[1]})",
274
- }
495
+ result = {}
496
+ return result
275
497
 
276
- # Mark biggest square.
277
- if cluster_state.max_square_size:
278
- for x in range(
279
- cluster_state.square_x,
280
- cluster_state.square_x + cluster_state.max_square_size,
281
- ):
282
- for y in range(
283
- cluster_state.square_y,
284
- cluster_state.square_y + cluster_state.max_square_size,
285
- ):
286
- tile_dict[(x, y)]["square"] = True
287
-
288
- # Add cluster information.
289
- for members in cluster_state.clusters.values():
290
- for member in members:
291
- tile_dict[member]["this_cluster_size"] = len(members)
292
- tile_dict[member]["cluster"] = True
293
- if len(cluster_state.cluster_evolution) > 0:
294
- max_cluster_size = cluster_state.cluster_evolution["max_cluster_size"].iloc[-1]
295
- else:
296
- max_cluster_size = 0
297
- num_cluster_tiles = len(cluster_state.memberships)
298
-
299
- # Apply cluster colors.
300
- cluster_cmap = matplotlib.colormaps["tab10"]
301
- for color, members in zip(
302
- itertools.cycle(map(cluster_cmap, [0, 1, 2, 3, 4, 5, 6, 8, 9])),
303
- sorted(
304
- cluster_state.clusters.values(),
305
- key=lambda members: len(members),
306
- reverse=True,
307
- ),
308
- ):
309
- hex_color = matplotlib.colors.to_hex(color)
310
- for member in members:
311
- tile_dict[member]["color"] = hex_color
312
-
313
- if cluster_state.max_square_size:
314
- square_geojson = geojson.dumps(
315
- geojson.FeatureCollection(
316
- features=[
317
- make_explorer_rectangle(
318
- cluster_state.square_x,
319
- cluster_state.square_y,
320
- cluster_state.square_x + cluster_state.max_square_size,
321
- cluster_state.square_y + cluster_state.max_square_size,
322
- zoom,
323
- )
324
- ]
325
- )
326
- )
327
- else:
328
- square_geojson = "{}"
329
-
330
- try:
331
- feature_collection = geojson.FeatureCollection(
332
- features=[
333
- make_explorer_tile(x, y, v, zoom) for (x, y), v in tile_dict.items()
334
- ]
335
- )
336
- explored_geojson = geojson.dumps(feature_collection)
337
- except TypeError as e:
338
- logger.error(f"Encountered TypeError while building GeoJSON: {e=}")
339
- logger.error(f"{tile_dict = }")
340
- raise
341
-
342
- result = {
343
- "explored_geojson": explored_geojson,
344
- "max_cluster_size": max_cluster_size,
345
- "num_cluster_tiles": num_cluster_tiles,
346
- "num_tiles": len(tile_dict),
347
- "square_size": cluster_state.max_square_size,
348
- "square_x": cluster_state.square_x,
349
- "square_y": cluster_state.square_y,
350
- "square_geojson": square_geojson,
351
- }
352
- return result
498
+ return blueprint
353
499
 
354
500
 
355
501
  def bounding_box_for_biggest_cluster(
356
- clusters: list[list[tuple[int, int]]], zoom: int
502
+ clusters: Iterable[list[tuple[int, int]]], zoom: int
357
503
  ) -> str:
358
504
  biggest_cluster = max(clusters, key=lambda members: len(members))
359
505
  min_x = min(x for x, y in biggest_cluster)
@@ -0,0 +1,31 @@
1
+ from flask import Blueprint
2
+ from flask import render_template
3
+ from flask import request
4
+ from flask import Response
5
+ from flask.typing import ResponseReturnValue
6
+
7
+ from ...core.export import export_all
8
+ from ..authenticator import Authenticator
9
+ from ..authenticator import needs_authentication
10
+
11
+
12
+ def make_export_blueprint(authenticator: Authenticator) -> Blueprint:
13
+ blueprint = Blueprint("export", __name__, template_folder="templates")
14
+
15
+ @needs_authentication(authenticator)
16
+ @blueprint.route("/")
17
+ def index() -> str:
18
+ return render_template("export/index.html.j2")
19
+
20
+ @needs_authentication(authenticator)
21
+ @blueprint.route("/export")
22
+ def export() -> Response:
23
+ meta_format = request.args["meta_format"]
24
+ activity_format = request.args["activity_format"]
25
+ return Response(
26
+ bytes(export_all(meta_format, activity_format)),
27
+ mimetype="application/zip",
28
+ headers={"Content-disposition": 'attachment; filename="export.zip"'},
29
+ )
30
+
31
+ return blueprint