geo-activity-playground 1.0.0__py3-none-any.whl → 1.2.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 (73) 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 +41 -3
  4. geo_activity_playground/core/parametric_plot.py +101 -47
  5. geo_activity_playground/webui/app.py +7 -0
  6. geo_activity_playground/webui/blueprints/activity_blueprint.py +11 -10
  7. geo_activity_playground/webui/blueprints/auth_blueprint.py +3 -2
  8. geo_activity_playground/webui/blueprints/bubble_chart_blueprint.py +2 -1
  9. geo_activity_playground/webui/blueprints/calendar_blueprint.py +3 -2
  10. geo_activity_playground/webui/blueprints/eddington_blueprints.py +3 -2
  11. geo_activity_playground/webui/blueprints/entry_views.py +11 -11
  12. geo_activity_playground/webui/blueprints/equipment_blueprint.py +2 -1
  13. geo_activity_playground/webui/blueprints/explorer_blueprint.py +47 -13
  14. geo_activity_playground/webui/blueprints/export_blueprint.py +3 -2
  15. geo_activity_playground/webui/blueprints/hall_of_fame_blueprint.py +79 -0
  16. geo_activity_playground/webui/blueprints/photo_blueprint.py +65 -56
  17. geo_activity_playground/webui/blueprints/plot_builder_blueprint.py +38 -19
  18. geo_activity_playground/webui/blueprints/settings_blueprint.py +17 -0
  19. geo_activity_playground/webui/blueprints/summary_blueprint.py +114 -240
  20. geo_activity_playground/webui/columns.py +40 -7
  21. geo_activity_playground/webui/static/{browserconfig.xml → favicons/browserconfig.xml} +1 -1
  22. geo_activity_playground/webui/static/{site.webmanifest → favicons/site.webmanifest} +2 -2
  23. geo_activity_playground/webui/static/server-side-explorer.js +7 -2
  24. geo_activity_playground/webui/templates/activity/name.html.j2 +4 -4
  25. geo_activity_playground/webui/templates/activity/show.html.j2 +8 -8
  26. geo_activity_playground/webui/templates/eddington/distance.html.j2 +3 -3
  27. geo_activity_playground/webui/templates/eddington/elevation_gain.html.j2 +3 -3
  28. geo_activity_playground/webui/templates/elevation_eddington/index.html.j2 +3 -3
  29. geo_activity_playground/webui/templates/equipment/index.html.j2 +4 -4
  30. geo_activity_playground/webui/templates/explorer/server-side.html.j2 +5 -4
  31. geo_activity_playground/webui/templates/hall_of_fame/index.html.j2 +57 -0
  32. geo_activity_playground/webui/templates/home.html.j2 +2 -12
  33. geo_activity_playground/webui/templates/page.html.j2 +23 -37
  34. geo_activity_playground/webui/templates/photo/new.html.j2 +1 -1
  35. geo_activity_playground/webui/templates/plot-macros.html.j2 +72 -0
  36. geo_activity_playground/webui/templates/plot_builder/edit.html.j2 +12 -7
  37. geo_activity_playground/webui/templates/plot_builder/import-spec.html.j2 +24 -0
  38. geo_activity_playground/webui/templates/plot_builder/index.html.j2 +5 -0
  39. geo_activity_playground/webui/templates/settings/index.html.j2 +9 -0
  40. geo_activity_playground/webui/templates/settings/tile-source.html.j2 +33 -0
  41. geo_activity_playground/webui/templates/summary/index.html.j2 +23 -230
  42. geo_activity_playground/webui/templates/summary/vega-chart.html.j2 +3 -0
  43. {geo_activity_playground-1.0.0.dist-info → geo_activity_playground-1.2.0.dist-info}/METADATA +1 -1
  44. {geo_activity_playground-1.0.0.dist-info → geo_activity_playground-1.2.0.dist-info}/RECORD +73 -66
  45. /geo_activity_playground/webui/static/{bootstrap-dark-mode.js → bootstrap/bootstrap-dark-mode.js} +0 -0
  46. /geo_activity_playground/webui/static/{bootstrap.bundle.min.js → bootstrap/bootstrap.bundle.min.js} +0 -0
  47. /geo_activity_playground/webui/static/{bootstrap.min.css → bootstrap/bootstrap.min.css} +0 -0
  48. /geo_activity_playground/webui/static/{android-chrome-192x192.png → favicons/android-chrome-192x192.png} +0 -0
  49. /geo_activity_playground/webui/static/{android-chrome-512x512.png → favicons/android-chrome-512x512.png} +0 -0
  50. /geo_activity_playground/webui/static/{apple-touch-icon.png → favicons/apple-touch-icon.png} +0 -0
  51. /geo_activity_playground/webui/static/{favicon-16x16.png → favicons/favicon-16x16.png} +0 -0
  52. /geo_activity_playground/webui/static/{favicon-32x32.png → favicons/favicon-32x32.png} +0 -0
  53. /geo_activity_playground/webui/static/{favicon-48x48.png → favicons/favicon-48x48.png} +0 -0
  54. /geo_activity_playground/webui/static/{favicon.ico → favicons/favicon.ico} +0 -0
  55. /geo_activity_playground/webui/static/{favicon.svg → favicons/favicon.svg} +0 -0
  56. /geo_activity_playground/webui/static/{mstile-150x150.png → favicons/mstile-150x150.png} +0 -0
  57. /geo_activity_playground/webui/static/{web-app-manifest-192x192.png → favicons/web-app-manifest-192x192.png} +0 -0
  58. /geo_activity_playground/webui/static/{web-app-manifest-512x512.png → favicons/web-app-manifest-512x512.png} +0 -0
  59. /geo_activity_playground/webui/static/{Leaflet.fullscreen.min.js → leaflet/Leaflet.fullscreen.min.js} +0 -0
  60. /geo_activity_playground/webui/static/{MarkerCluster.Default.css → leaflet/MarkerCluster.Default.css} +0 -0
  61. /geo_activity_playground/webui/static/{MarkerCluster.css → leaflet/MarkerCluster.css} +0 -0
  62. /geo_activity_playground/webui/static/{fullscreen.png → leaflet/fullscreen.png} +0 -0
  63. /geo_activity_playground/webui/static/{fullscreen@2x.png → leaflet/fullscreen@2x.png} +0 -0
  64. /geo_activity_playground/webui/static/{leaflet.css → leaflet/leaflet.css} +0 -0
  65. /geo_activity_playground/webui/static/{leaflet.fullscreen.css → leaflet/leaflet.fullscreen.css} +0 -0
  66. /geo_activity_playground/webui/static/{leaflet.js → leaflet/leaflet.js} +0 -0
  67. /geo_activity_playground/webui/static/{leaflet.markercluster.js → leaflet/leaflet.markercluster.js} +0 -0
  68. /geo_activity_playground/webui/static/{vega-embed@6 → vega/vega-embed@6.js} +0 -0
  69. /geo_activity_playground/webui/static/{vega-lite@4 → vega/vega-lite@4.js} +0 -0
  70. /geo_activity_playground/webui/static/{vega@5 → vega/vega@5.js} +0 -0
  71. {geo_activity_playground-1.0.0.dist-info → geo_activity_playground-1.2.0.dist-info}/LICENSE +0 -0
  72. {geo_activity_playground-1.0.0.dist-info → geo_activity_playground-1.2.0.dist-info}/WHEEL +0 -0
  73. {geo_activity_playground-1.0.0.dist-info → geo_activity_playground-1.2.0.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  import abc
2
2
  import datetime
3
+ import hashlib
3
4
  import io
4
5
  import itertools
5
6
  import logging
@@ -21,6 +22,7 @@ from flask import render_template
21
22
  from flask import request
22
23
  from flask import Response
23
24
  from flask import url_for
25
+ from flask.typing import ResponseReturnValue
24
26
 
25
27
  from ...core.activities import ActivityRepository
26
28
  from ...core.config import ConfigAccessor
@@ -62,7 +64,7 @@ class ColorStrategy(abc.ABC):
62
64
  pass
63
65
 
64
66
 
65
- class ClusterColorStrategy(ColorStrategy):
67
+ class MaxClusterColorStrategy(ColorStrategy):
66
68
  def __init__(self, evolution_state, tile_visits):
67
69
  self.evolution_state = evolution_state
68
70
  self.tile_visits = tile_visits
@@ -84,6 +86,31 @@ class ClusterColorStrategy(ColorStrategy):
84
86
  return grayscale
85
87
 
86
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
+
87
114
  class VisitTimeColorStrategy(ColorStrategy):
88
115
  def __init__(self, tile_visits, use_first=True):
89
116
  self.tile_visits = tile_visits
@@ -133,7 +160,7 @@ def make_explorer_blueprint(
133
160
 
134
161
  @blueprint.route("/enable-zoom-level/<int:zoom>")
135
162
  @needs_authentication(authenticator)
136
- def enable_zoom_level(zoom: int):
163
+ def enable_zoom_level(zoom: int) -> ResponseReturnValue:
137
164
  if 0 <= zoom <= 19:
138
165
  config_accessor().explorer_zoom_levels.append(zoom)
139
166
  config_accessor().explorer_zoom_levels.sort()
@@ -145,11 +172,11 @@ def make_explorer_blueprint(
145
172
  return redirect(url_for(".map", zoom=zoom))
146
173
 
147
174
  @blueprint.route(
148
- "/<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>"
149
176
  )
150
- def download(
177
+ def download_missing(
151
178
  zoom: int, north: float, east: float, south: float, west: float, suffix: str
152
- ):
179
+ ) -> ResponseReturnValue:
153
180
  x1, y1 = compute_tile(north, west, zoom)
154
181
  x2, y2 = compute_tile(south, east, zoom)
155
182
  tile_bounds = Bounds(x1, y1, x2 + 2, y2 + 2)
@@ -170,11 +197,11 @@ def make_explorer_blueprint(
170
197
  )
171
198
 
172
199
  @blueprint.route(
173
- "/<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>"
174
201
  )
175
- def missing(
202
+ def download_explored(
176
203
  zoom: int, north: float, east: float, south: float, west: float, suffix: str
177
- ):
204
+ ) -> ResponseReturnValue:
178
205
  x1, y1 = compute_tile(north, west, zoom)
179
206
  x2, y2 = compute_tile(south, east, zoom)
180
207
  tile_bounds = Bounds(x1, y1, x2 + 2, y2 + 2)
@@ -197,7 +224,7 @@ def make_explorer_blueprint(
197
224
  )
198
225
 
199
226
  @blueprint.route("/<int:zoom>/server-side")
200
- def server_side(zoom: int):
227
+ def server_side(zoom: int) -> ResponseReturnValue:
201
228
  if zoom not in config_accessor().explorer_zoom_levels:
202
229
  return {"zoom_level_not_generated": zoom}
203
230
 
@@ -239,7 +266,7 @@ def make_explorer_blueprint(
239
266
  return render_template("explorer/server-side.html.j2", **context)
240
267
 
241
268
  @blueprint.route("/<int:zoom>/tile/<int:z>/<int:x>/<int:y>.png")
242
- def tile(zoom: int, z: int, x: int, y: int) -> Response:
269
+ def tile(zoom: int, z: int, x: int, y: int) -> ResponseReturnValue:
243
270
  tile_visits = tile_visit_accessor.tile_state["tile_visits"][zoom]
244
271
  evolution_state = tile_visit_accessor.tile_state["evolution_state"][zoom]
245
272
 
@@ -248,9 +275,16 @@ def make_explorer_blueprint(
248
275
  square_line_width = 3
249
276
  square_color = np.array([[[228, 26, 28]]]) / 256
250
277
 
251
- match request.args.get("color_strategy", "cluster"):
252
- case "cluster":
253
- color_strategy = ClusterColorStrategy(evolution_state, tile_visits)
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
+ )
254
288
  case "first":
255
289
  color_strategy = VisitTimeColorStrategy(tile_visits, use_first=True)
256
290
  case "last":
@@ -2,6 +2,7 @@ from flask import Blueprint
2
2
  from flask import render_template
3
3
  from flask import request
4
4
  from flask import Response
5
+ from flask.typing import ResponseReturnValue
5
6
 
6
7
  from ...core.export import export_all
7
8
  from ..authenticator import Authenticator
@@ -13,12 +14,12 @@ def make_export_blueprint(authenticator: Authenticator) -> Blueprint:
13
14
 
14
15
  @needs_authentication(authenticator)
15
16
  @blueprint.route("/")
16
- def index():
17
+ def index() -> str:
17
18
  return render_template("export/index.html.j2")
18
19
 
19
20
  @needs_authentication(authenticator)
20
21
  @blueprint.route("/export")
21
- def export():
22
+ def export() -> Response:
22
23
  meta_format = request.args["meta_format"]
23
24
  activity_format = request.args["activity_format"]
24
25
  return Response(
@@ -0,0 +1,79 @@
1
+ import collections
2
+
3
+ import pandas as pd
4
+ from flask import Blueprint
5
+ from flask import render_template
6
+ from flask import request
7
+
8
+ from ...core.activities import ActivityRepository
9
+ from ...core.activities import make_geojson_from_time_series
10
+ from ...core.meta_search import apply_search_query
11
+ from ..search_util import search_query_from_form
12
+ from ..search_util import SearchQueryHistory
13
+
14
+
15
+ def make_hall_of_fame_blueprint(
16
+ repository: ActivityRepository,
17
+ search_query_history: SearchQueryHistory,
18
+ ) -> Blueprint:
19
+ blueprint = Blueprint("hall_of_fame", __name__, template_folder="templates")
20
+
21
+ @blueprint.route("/")
22
+ def index() -> str:
23
+ query = search_query_from_form(request.args)
24
+ search_query_history.register_query(query)
25
+ activities = apply_search_query(repository.meta, query)
26
+ df = activities
27
+
28
+ nominations = nominate_activities(df)
29
+
30
+ return render_template(
31
+ "hall_of_fame/index.html.j2",
32
+ nominations=[
33
+ (
34
+ repository.get_activity_by_id(activity_id),
35
+ reasons,
36
+ make_geojson_from_time_series(
37
+ repository.get_time_series(activity_id)
38
+ ),
39
+ )
40
+ for activity_id, reasons in nominations.items()
41
+ ],
42
+ query=query.to_jinja(),
43
+ )
44
+
45
+ return blueprint
46
+
47
+
48
+ def nominate_activities(meta: pd.DataFrame) -> dict[int, list[str]]:
49
+ nominations: dict[int, list[str]] = collections.defaultdict(list)
50
+
51
+ _nominate_activities_inner(meta, "", nominations)
52
+
53
+ for kind, group in meta.groupby("kind"):
54
+ _nominate_activities_inner(group, f" for {kind}", nominations)
55
+ for equipment, group in meta.groupby("equipment"):
56
+ _nominate_activities_inner(group, f" with {equipment}", nominations)
57
+
58
+ return nominations
59
+
60
+
61
+ def _nominate_activities_inner(
62
+ meta: pd.DataFrame, title_suffix: str, nominations: dict[int, list[str]]
63
+ ) -> None:
64
+ ratings = [
65
+ ("distance_km", "Greatest distance", "{:.1f} km"),
66
+ ("elapsed_time", "Longest elapsed time", "{}"),
67
+ ("average_speed_moving_kmh", "Highest average moving speed", "{:.1f} km/h"),
68
+ ("average_speed_elapsed_kmh", "Highest average elapsed speed", "{:.1f} km/h"),
69
+ ("calories", "Most calories burnt", "{:.0f}"),
70
+ ("steps", "Most steps", "{:.0f}"),
71
+ ("elevation_gain", "Largest elevation gain", "{:.0f} m"),
72
+ ]
73
+
74
+ for variable, title, format_str in ratings:
75
+ if variable in meta.columns and not pd.isna(meta[variable]).all():
76
+ i = meta[variable].idxmax()
77
+ value = meta.loc[i, variable]
78
+ format_applied = format_str.format(value)
79
+ nominations[i].append(f"{title}{title_suffix}: {format_applied}")
@@ -12,6 +12,7 @@ from flask import render_template
12
12
  from flask import request
13
13
  from flask import Response
14
14
  from flask import url_for
15
+ from flask.typing import ResponseReturnValue
15
16
  from PIL import Image
16
17
  from PIL import ImageOps
17
18
 
@@ -123,7 +124,7 @@ def make_photo_blueprint(
123
124
 
124
125
  @blueprint.route("/new", methods=["GET", "POST"])
125
126
  @needs_authentication(authenticator)
126
- def new() -> Response:
127
+ def new() -> ResponseReturnValue:
127
128
  if request.method == "POST":
128
129
  # check if the post request has the file part
129
130
  if "file" not in request.files:
@@ -132,66 +133,74 @@ def make_photo_blueprint(
132
133
  )
133
134
  return redirect(url_for(".new"))
134
135
 
135
- file = request.files["file"]
136
- # If the user does not select a file, the browser submits an
137
- # empty file without a filename.
138
- if file.filename == "":
139
- flasher.flash_message("No selected file.", FlashTypes.WARNING)
140
- return redirect(url_for(".new"))
141
- if not file:
142
- flasher.flash_message("Empty file uploaded.", FlashTypes.WARNING)
143
- return redirect(url_for(".new"))
136
+ new_photos: list[Photo] = []
137
+ for file in request.files.getlist("file"):
138
+ # If the user does not select a file, the browser submits an
139
+ # empty file without a filename.
140
+ if file.filename == "":
141
+ flasher.flash_message("No selected file.", FlashTypes.WARNING)
142
+ return redirect(url_for(".new"))
143
+ if not file:
144
+ flasher.flash_message("Empty file uploaded.", FlashTypes.WARNING)
145
+ return redirect(url_for(".new"))
146
+
147
+ filename = str(uuid.uuid4()) + pathlib.Path(file.filename).suffix
148
+ path = PHOTOS_DIR() / "original" / filename
149
+ path.parent.mkdir(exist_ok=True)
150
+ file.save(path)
151
+ metadata = get_metadata_from_image(path)
152
+
153
+ if "time" not in metadata:
154
+ flasher.flash_message(
155
+ f"Your image '{file.filename}' doesn't have the EXIF attribute 'EXIF DateTimeOriginal' and hence cannot be dated.",
156
+ FlashTypes.DANGER,
157
+ )
158
+ continue
159
+ time: datetime.datetime = metadata["time"]
160
+
161
+ activity = DB.session.scalar(
162
+ sqlalchemy.select(Activity)
163
+ .where(
164
+ Activity.start.is_not(None),
165
+ Activity.elapsed_time.is_not(None),
166
+ Activity.start <= time,
167
+ )
168
+ .order_by(Activity.start.desc())
169
+ .limit(1)
170
+ )
171
+ if activity is None or activity.start + activity.elapsed_time < time:
172
+ flasher.flash_message(
173
+ f"Your image '{file.filename}' is from {time} but no activity could be found. Please first upload an activity or fix the time in the photo.",
174
+ FlashTypes.DANGER,
175
+ )
176
+ continue
177
+
178
+ if "latitude" not in metadata:
179
+ time_series = activity.time_series
180
+ print(time_series)
181
+ row = time_series.loc[time_series["time"] >= time].iloc[0]
182
+ metadata["latitude"] = row["latitude"]
183
+ metadata["longitude"] = row["longitude"]
184
+
185
+ photo = Photo(
186
+ filename=filename,
187
+ time=time,
188
+ latitude=metadata["latitude"],
189
+ longitude=metadata["longitude"],
190
+ activity=activity,
191
+ )
144
192
 
145
- filename = str(uuid.uuid4()) + pathlib.Path(file.filename).suffix
146
- path = PHOTOS_DIR() / "original" / filename
147
- path.parent.mkdir(exist_ok=True)
148
- file.save(path)
149
- metadata = get_metadata_from_image(path)
193
+ DB.session.add(photo)
194
+ DB.session.commit()
195
+ new_photos.append(photo)
150
196
 
151
- if "time" not in metadata:
197
+ if new_photos:
152
198
  flasher.flash_message(
153
- "Your image doesn't have the EXIF attribute 'EXIF DateTimeOriginal' and hence cannot be dated.",
154
- FlashTypes.DANGER,
199
+ f"Added {len(new_photos)} new photos.", FlashTypes.SUCCESS
155
200
  )
201
+ return redirect(f"/activity/{new_photos[-1].activity.id}")
202
+ else:
156
203
  return redirect(url_for(".new"))
157
- time: datetime.datetime = metadata["time"]
158
-
159
- activity = DB.session.scalar(
160
- sqlalchemy.select(Activity)
161
- .where(
162
- Activity.start.is_not(None),
163
- Activity.elapsed_time.is_not(None),
164
- Activity.start <= time,
165
- )
166
- .order_by(Activity.start.desc())
167
- .limit(1)
168
- )
169
- if activity is None or activity.start + activity.elapsed_time < time:
170
- flasher.flash_message(
171
- f"Your image is from {time} but no activity could be found. Please first upload an activity or fix the time in the photo",
172
- FlashTypes.DANGER,
173
- )
174
- print(activity)
175
-
176
- if "latitude" not in metadata:
177
- time_series = activity.time_series
178
- print(time_series)
179
- row = time_series.loc[time_series["time"] >= time].iloc[0]
180
- metadata["latitude"] = row["latitude"]
181
- metadata["longitude"] = row["longitude"]
182
-
183
- photo = Photo(
184
- filename=filename,
185
- time=time,
186
- latitude=metadata["latitude"],
187
- longitude=metadata["longitude"],
188
- activity=activity,
189
- )
190
-
191
- DB.session.add(photo)
192
- DB.session.commit()
193
-
194
- return redirect(f"/activity/{activity.id}")
195
204
  else:
196
205
  return render_template("photo/new.html.j2")
197
206
 
@@ -1,3 +1,5 @@
1
+ import json
2
+
1
3
  import sqlalchemy
2
4
  from flask import Blueprint
3
5
  from flask import redirect
@@ -5,9 +7,12 @@ from flask import render_template
5
7
  from flask import request
6
8
  from flask import Response
7
9
  from flask import url_for
10
+ from flask.typing import ResponseReturnValue
11
+ from flask.typing import RouteCallable
8
12
 
9
13
  from ...core.activities import ActivityRepository
10
14
  from ...core.datamodel import DB
15
+ from ...core.parametric_plot import GROUP_BY_VARIABLES
11
16
  from ...core.parametric_plot import make_parametric_plot
12
17
  from ...core.parametric_plot import MARKS
13
18
  from ...core.parametric_plot import PlotSpec
@@ -25,7 +30,7 @@ def make_plot_builder_blueprint(
25
30
  blueprint = Blueprint("plot_builder", __name__, template_folder="templates")
26
31
 
27
32
  @blueprint.route("/")
28
- def index() -> Response:
33
+ def index() -> ResponseReturnValue:
29
34
  return render_template(
30
35
  "plot_builder/index.html.j2",
31
36
  specs=DB.session.scalars(sqlalchemy.select(PlotSpec)).all(),
@@ -33,7 +38,7 @@ def make_plot_builder_blueprint(
33
38
 
34
39
  @blueprint.route("/new")
35
40
  @needs_authentication(authenticator)
36
- def new() -> Response:
41
+ def new() -> ResponseReturnValue:
37
42
  spec = PlotSpec(
38
43
  name="My New Plot",
39
44
  mark="bar",
@@ -45,23 +50,36 @@ def make_plot_builder_blueprint(
45
50
  DB.session.commit()
46
51
  return redirect(url_for(".edit", id=spec.id))
47
52
 
48
- @blueprint.route("/edit/<int:id>")
53
+ @blueprint.route("/import-spec", methods=["GET", "POST"])
49
54
  @needs_authentication(authenticator)
50
- def edit(id: int) -> Response:
51
- spec = DB.session.get(PlotSpec, id)
52
- if request.args:
53
- spec.name = request.args["name"]
54
- spec.mark = request.args["mark"]
55
- spec.x = request.args["x"]
56
- spec.y = request.args["y"]
57
- spec.color = request.args["color"]
58
- spec.shape = request.args["shape"]
59
- spec.size = request.args["size"]
60
- spec.size = request.args["size"]
61
- spec.row = request.args["row"]
62
- spec.column = request.args["column"]
63
- spec.facet = request.args["facet"]
64
- spec.opacity = request.args["opacity"]
55
+ def import_spec() -> ResponseReturnValue:
56
+ if request.form:
57
+ parameters = json.loads(request.form["spec_json"])
58
+ spec = PlotSpec(**parameters)
59
+ DB.session.add(spec)
60
+ DB.session.commit()
61
+ return redirect(url_for(".edit", id=spec.id))
62
+ else:
63
+ return render_template("plot_builder/import-spec.html.j2")
64
+
65
+ @blueprint.route("/edit/<int:id>", methods=["GET", "POST"])
66
+ @needs_authentication(authenticator)
67
+ def edit(id: int) -> ResponseReturnValue:
68
+ spec = DB.session.get_one(PlotSpec, id)
69
+ if request.form:
70
+ spec.name = request.form["name"]
71
+ spec.mark = request.form["mark"]
72
+ spec.x = request.form["x"]
73
+ spec.y = request.form["y"]
74
+ spec.color = request.form["color"]
75
+ spec.shape = request.form["shape"]
76
+ spec.size = request.form["size"]
77
+ spec.size = request.form["size"]
78
+ spec.row = request.form["row"]
79
+ spec.column = request.form["column"]
80
+ spec.facet = request.form["facet"]
81
+ spec.opacity = request.form["opacity"]
82
+ spec.group_by = request.form["group_by"]
65
83
  try:
66
84
  plot = make_parametric_plot(repository.meta, spec)
67
85
  DB.session.commit()
@@ -73,13 +91,14 @@ def make_plot_builder_blueprint(
73
91
  marks=MARKS,
74
92
  discrete=VARIABLES_1,
75
93
  continuous=VARIABLES_2,
94
+ group_by=GROUP_BY_VARIABLES,
76
95
  plot=plot,
77
96
  spec=spec,
78
97
  )
79
98
 
80
99
  @blueprint.route("/delete/<int:id>")
81
100
  @needs_authentication(authenticator)
82
- def delete(id: int) -> Response:
101
+ def delete(id: int) -> ResponseReturnValue:
83
102
  spec = DB.session.get(PlotSpec, id)
84
103
  DB.session.delete(spec)
85
104
  flasher.flash_message(f"Deleted plot '{spec.name}'.", FlashTypes.SUCCESS)
@@ -447,6 +447,23 @@ def make_settings_blueprint(
447
447
  else:
448
448
  return render_template("settings/tags-edit.html.j2", tag=tag)
449
449
 
450
+ @blueprint.route("/tile-source", methods=["GET", "POST"])
451
+ @needs_authentication(authenticator)
452
+ def tile_source() -> str:
453
+ if request.method == "POST":
454
+ config_accessor().map_tile_url = request.form["map_tile_url"]
455
+ config_accessor().map_tile_attribution = request.form[
456
+ "map_tile_attribution"
457
+ ]
458
+ config_accessor.save()
459
+ flasher.flash_message("Tile source updated.", FlashTypes.SUCCESS)
460
+ return render_template(
461
+ "settings/tile-source.html.j2",
462
+ map_tile_url=config_accessor().map_tile_url,
463
+ map_tile_attribution=config_accessor().map_tile_attribution,
464
+ test_url=config_accessor().map_tile_url.format(zoom=14, x=8514, y=5504),
465
+ )
466
+
450
467
  return blueprint
451
468
 
452
469