geovisio 2.4.0__py3-none-any.whl → 2.6.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.
geovisio/web/map.py CHANGED
@@ -3,9 +3,9 @@
3
3
 
4
4
  import psycopg
5
5
  import io
6
- from typing import Optional, Dict, Any, Tuple, List
6
+ from typing import Optional, Dict, Any, Tuple, List, Union
7
7
  from uuid import UUID
8
- from flask import Blueprint, current_app, send_file, request
8
+ from flask import Blueprint, current_app, send_file, request, jsonify, url_for
9
9
  from geovisio.utils import auth
10
10
  from geovisio.utils.auth import Account
11
11
  from geovisio.web import params
@@ -15,6 +15,91 @@ from psycopg import sql
15
15
 
16
16
  bp = Blueprint("map", __name__, url_prefix="/api")
17
17
 
18
+ ZOOM_GRID_SEQUENCES = 6
19
+ ZOOM_PICTURES = 15
20
+
21
+
22
+ def get_style_json(forUser: Optional[Union[UUID, str]] = None):
23
+ # Get correct vector tiles URL
24
+ tilesUrl = url_for("map.getTile", x="111", y="222", z="333", format="mvt", _external=True)
25
+ sourceId = "geovisio"
26
+ if forUser == "me":
27
+ tilesUrl = url_for("map.getMyTile", x="111", y="222", z="333", format="mvt", _external=True)
28
+ sourceId = "geovisio_me"
29
+ elif forUser is not None:
30
+ tilesUrl = url_for("map.getUserTile", userId=forUser, x="111", y="222", z="333", format="mvt", _external=True)
31
+ sourceId = f"geovisio_{str(forUser)}"
32
+ tilesUrl = tilesUrl.replace("111", "{x}").replace("222", "{y}").replace("333", "{z}")
33
+
34
+ # Display sequence on all zooms if user tiles, after grid on general tiles
35
+ sequenceOpacity = ["interpolate", ["linear"], ["zoom"], ZOOM_GRID_SEQUENCES, 0, ZOOM_GRID_SEQUENCES + 1, 1] if forUser is None else 1
36
+
37
+ layers = [
38
+ {
39
+ "id": f"{sourceId}_sequences",
40
+ "type": "line",
41
+ "source": sourceId,
42
+ "source-layer": "sequences",
43
+ "paint": {
44
+ "line-color": "#FF6F00",
45
+ "line-width": ["interpolate", ["linear"], ["zoom"], 0, 0.5, 10, 2, 14, 4, 16, 5, 22, 3],
46
+ "line-opacity": sequenceOpacity,
47
+ },
48
+ "layout": {
49
+ "line-cap": "square",
50
+ },
51
+ },
52
+ {
53
+ "id": f"{sourceId}_pictures",
54
+ "type": "circle",
55
+ "source": sourceId,
56
+ "source-layer": "pictures",
57
+ "paint": {
58
+ "circle-color": "#FF6F00",
59
+ "circle-radius": ["interpolate", ["linear"], ["zoom"], ZOOM_PICTURES, 4.5, 17, 8, 22, 12],
60
+ "circle-opacity": ["interpolate", ["linear"], ["zoom"], ZOOM_PICTURES, 0, ZOOM_PICTURES + 1, 1],
61
+ "circle-stroke-color": "#ffffff",
62
+ "circle-stroke-width": ["interpolate", ["linear"], ["zoom"], 17, 0, 20, 2],
63
+ },
64
+ },
65
+ ]
66
+
67
+ # Grid layer of general tiles
68
+ if forUser is None:
69
+ layers.append(
70
+ {
71
+ "id": f"{sourceId}_grid",
72
+ "type": "fill",
73
+ "source": sourceId,
74
+ "source-layer": "grid",
75
+ "paint": {
76
+ "fill-color": ["interpolate-hcl", ["linear"], ["get", "coef"], 0, "#FFCC80", 0.5, "#E65100", 1, "#BF360C"],
77
+ "fill-opacity": [
78
+ "interpolate",
79
+ ["linear"],
80
+ ["zoom"],
81
+ 0,
82
+ 1,
83
+ ZOOM_GRID_SEQUENCES - 2,
84
+ 1,
85
+ ZOOM_GRID_SEQUENCES,
86
+ 0.8,
87
+ ZOOM_GRID_SEQUENCES + 0.5,
88
+ 0,
89
+ ],
90
+ },
91
+ }
92
+ )
93
+
94
+ style = {
95
+ "version": 8,
96
+ "name": "GeoVisio Vector Tiles",
97
+ "sources": {sourceId: {"type": "vector", "tiles": [tilesUrl], "minzoom": 0, "maxzoom": ZOOM_PICTURES}},
98
+ "layers": layers,
99
+ }
100
+
101
+ return jsonify(style)
102
+
18
103
 
19
104
  def checkTileValidity(z, x, y, format):
20
105
  """Check if tile parameters are valid
@@ -44,8 +129,8 @@ def checkTileValidity(z, x, y, format):
44
129
  raise errors.InvalidAPIUsage("X or Y parameter is out of bounds", status_code=404)
45
130
  if x < 0 or y < 0:
46
131
  raise errors.InvalidAPIUsage("X or Y parameter is out of bounds", status_code=404)
47
- if z < 0 or z > 14:
48
- raise errors.InvalidAPIUsage("Z parameter is out of bounds (should be 0-14)", status_code=404)
132
+ if z < 0 or z > 15:
133
+ raise errors.InvalidAPIUsage("Z parameter is out of bounds (should be 0-15)", status_code=404)
49
134
 
50
135
 
51
136
  def _getTile(z: int, x: int, y: int, format: str, onlyForUser: Optional[UUID] = None, filter: Optional[sql.SQL] = None):
@@ -63,18 +148,38 @@ def _getTile(z: int, x: int, y: int, format: str, onlyForUser: Optional[UUID] =
63
148
  return send_file(io.BytesIO(res), mimetype="application/vnd.mapbox-vector-tile")
64
149
 
65
150
 
151
+ @bp.route("/map/style.json")
152
+ @user_dependant_response(False)
153
+ def getStyle():
154
+ """Get vector tiles style.
155
+
156
+ This style file follows MapLibre Style Spec : https://maplibre.org/maplibre-style-spec/
157
+
158
+ ---
159
+ tags:
160
+ - Map
161
+ responses:
162
+ 200:
163
+ description: Vector tiles style JSON
164
+ content:
165
+ application/json:
166
+ schema:
167
+ $ref: '#/components/schemas/MapLibreStyleJSON'
168
+ """
169
+ return get_style_json()
170
+
171
+
66
172
  @bp.route("/map/<int:z>/<int:x>/<int:y>.<format>")
67
173
  @user_dependant_response(False)
68
174
  def getTile(z: int, x: int, y: int, format: str):
69
175
  """Get pictures and sequences as vector tiles
70
176
 
71
- Vector tiles contains possibly two layers : sequences and pictures.
177
+ Vector tiles contains different layers based on zoom level : sequences, pictures or grid.
72
178
 
73
179
  Layer "sequences":
74
- - Available on all zoom levels
75
- - Available properties (all levels)
180
+ - Available on zoom levels >= 6
181
+ - Available properties:
76
182
  - id (sequence ID)
77
- - Other properties (available on zoom levels >= 13)
78
183
  - account_id
79
184
  - model (camera make and model)
80
185
  - type (flat or equirectangular)
@@ -90,6 +195,14 @@ def getTile(z: int, x: int, y: int, format: str):
90
195
  - sequences (list of sequences ID this pictures belongs to)
91
196
  - type (flat or equirectangular)
92
197
  - model (camera make and model)
198
+
199
+ Layer "grid":
200
+ - Available on zoom levels 0 to 5 (included)
201
+ - Available properties:
202
+ - id
203
+ - nb_pictures
204
+ - coef (value from 0 to 1, relative quantity of available pictures)
205
+
93
206
  ---
94
207
  tags:
95
208
  - Map
@@ -135,27 +248,24 @@ def getTile(z: int, x: int, y: int, format: str):
135
248
  def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_filter: Optional[sql.SQL]) -> Tuple[sql.Composed, Dict]:
136
249
  """Returns appropriate SQL query according to given zoom"""
137
250
 
138
- sequences_filter: List[sql.Composable] = [sql.SQL("s.geom && ST_Transform(ST_TileEnvelope(%(z)s, %(x)s, %(y)s), 4326)")]
139
- pictures_filter: List[sql.Composable] = [sql.SQL("p.geom && ST_Transform(ST_TileEnvelope(%(z)s, %(x)s, %(y)s), 4326)")]
140
251
  params: Dict[str, Any] = {"x": x, "y": y, "z": z}
141
252
 
142
- account = auth.get_current_account()
143
- accountId = account.id if account is not None else None
144
- # we never want to display deleted sequences on the map
145
- sequences_filter.append(sql.SQL("s.status != 'deleted'"))
146
- pictures_filter.append(sql.SQL("p.status != 'waiting-for-delete'"))
147
-
148
- if onlyForUser:
149
- sequences_filter.append(sql.SQL("s.account_id = %(account)s"))
150
- pictures_filter.append(sql.SQL("p.account_id = %(account)s"))
151
- params["account"] = onlyForUser
253
+ #############################################################
254
+ # SQL Filters
255
+ #
152
256
 
153
- # we want to show only 'ready' collection to the general users
154
- if not onlyForUser or accountId != str(onlyForUser):
155
- sequences_filter.append(sql.SQL("s.status = 'ready'"))
156
- pictures_filter.append(sql.SQL("p.status = 'ready'"))
157
- pictures_filter.append(sql.SQL("s.status = 'ready'"))
257
+ # Basic filters
258
+ grid_filter: List[sql.Composable] = [sql.SQL("g.geom && ST_Transform(ST_TileEnvelope(%(z)s, %(x)s, %(y)s), 4326)")]
259
+ sequences_filter: List[sql.Composable] = [
260
+ sql.SQL("s.status != 'deleted'"), # we never want to display deleted sequences on the map
261
+ sql.SQL("s.geom && ST_Transform(ST_TileEnvelope(%(z)s, %(x)s, %(y)s), 4326)"),
262
+ ]
263
+ pictures_filter: List[sql.Composable] = [
264
+ sql.SQL("p.status != 'waiting-for-delete'"), # we never want to display deleted pictures on the map
265
+ sql.SQL("p.geom && ST_Transform(ST_TileEnvelope(%(z)s, %(x)s, %(y)s), 4326)"),
266
+ ]
158
267
 
268
+ # Supplementary filters
159
269
  if additional_filter:
160
270
  sequences_filter.append(additional_filter)
161
271
  filter_str = additional_filter.as_string(None)
@@ -166,16 +276,46 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
166
276
  pic_additional_filter = sql.SQL(pic_additional_filter_str) # type: ignore
167
277
  pictures_filter.append(sql.SQL("(") + sql.SQL(" OR ").join([pic_additional_filter, additional_filter]) + sql.SQL(")"))
168
278
 
279
+ # Per-user filters
280
+ if onlyForUser:
281
+ sequences_filter.append(sql.SQL("s.account_id = %(account)s"))
282
+ pictures_filter.append(sql.SQL("p.account_id = %(account)s"))
283
+ params["account"] = onlyForUser
284
+
285
+ # Not logged-in requests -> only show "ready" pics/sequences
286
+ account = auth.get_current_account()
287
+ accountId = account.id if account is not None else None
288
+ if not onlyForUser or accountId != str(onlyForUser):
289
+ sequences_filter.append(sql.SQL("s.status = 'ready'"))
290
+ pictures_filter.append(sql.SQL("p.status = 'ready'"))
291
+ pictures_filter.append(sql.SQL("s.status = 'ready'"))
292
+
293
+ #############################################################
294
+ # SQL Result columns/fields
295
+ #
296
+
297
+ grid_fields = [
298
+ sql.SQL("ST_AsMVTGeom(ST_Transform(geom, 3857), ST_TileEnvelope(%(z)s, %(x)s, %(y)s)) AS geom"),
299
+ sql.SQL("id"),
300
+ sql.SQL("nb_pictures"),
301
+ sql.SQL(
302
+ """
303
+ ((CASE WHEN nb_pictures <= (select PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) from pictures_grid)
304
+ THEN nb_pictures::float / (select PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY nb_pictures) from pictures_grid) * 0.5
305
+ ELSE 0.5 + nb_pictures::float / (SELECT MAX(nb_pictures) FROM pictures_grid) * 0.5
306
+ END) * 10)::int / 10::float AS coef
307
+ """
308
+ ),
309
+ ]
169
310
  sequences_fields = [
170
311
  sql.SQL("ST_AsMVTGeom(ST_Transform(geom, 3857), ST_TileEnvelope(%(z)s, %(x)s, %(y)s)) AS geom"),
171
312
  sql.SQL("id"),
172
313
  ]
173
314
  simplified_sequence_fields = [
174
- sql.SQL("ST_Simplify(geom, 0.01) AS geom"),
175
- sql.SQL("id"),
176
- sql.SQL("status"),
315
+ sql.SQL("ST_AsMVTGeom(ST_Transform(ST_Simplify(geom, 0.01), 3857), ST_TileEnvelope(%(z)s, %(x)s, %(y)s)) AS geom"),
177
316
  ]
178
- if z >= 7 or onlyForUser:
317
+
318
+ if z >= ZOOM_GRID_SEQUENCES or onlyForUser:
179
319
  sequences_fields.extend(
180
320
  [
181
321
  sql.SQL("account_id"),
@@ -185,91 +325,146 @@ def _get_query(z: int, x: int, y: int, onlyForUser: Optional[UUID], additional_f
185
325
  sql.SQL("computed_capture_date AS date"),
186
326
  ]
187
327
  )
188
- simplified_sequence_fields.extend(
189
- [
190
- sql.SQL("account_id"),
191
- sql.SQL("computed_model"),
192
- sql.SQL("computed_type"),
193
- sql.SQL("computed_capture_date"),
194
- ]
195
- )
196
- if z >= 13:
328
+
329
+ #############################################################
330
+ # SQL Full requests
331
+ #
332
+
333
+ # Full pictures + sequences (z15+)
334
+ if z >= ZOOM_PICTURES:
197
335
  query = sql.SQL(
198
336
  """
199
- SELECT mvtsequences.mvt || mvtpictures.mvt
200
- FROM (
201
- SELECT ST_AsMVT(mvtgeomseqs.*, 'sequences') AS mvt
202
- FROM (
203
- SELECT
204
- {sequences_fields}
205
- FROM sequences s
206
- WHERE
207
- {sequences_filter}
208
- ) mvtgeomseqs
209
- ) mvtsequences,
210
- (
211
- SELECT ST_AsMVT(mvtgeompics.*, 'pictures') AS mvt
212
- FROM (
213
- SELECT
214
- ST_AsMVTGeom(ST_Transform(p.geom, 3857), ST_TileEnvelope(%(z)s, %(x)s, %(y)s)) AS geom,
215
- p.id, p.ts, p.heading, p.account_id,
216
- NULLIF(p.status != 'ready' OR s.status != 'ready', FALSE) AS hidden,
217
- array_to_json(ARRAY_AGG(sp.seq_id)) AS sequences,
218
- p.metadata->>'type' AS type,
219
- TRIM(CONCAT(p.metadata->>'make', ' ', p.metadata->>'model')) AS model
220
- FROM pictures p
221
- LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
222
- LEFT JOIN sequences s ON s.id = sp.seq_id
223
- WHERE
224
- {pictures_filter}
225
- GROUP BY 1, 2, 3, 4, 5, 6
226
- ) mvtgeompics
227
- ) mvtpictures
228
- """
337
+ SELECT mvtsequences.mvt || mvtpictures.mvt
338
+ FROM (
339
+ SELECT ST_AsMVT(mvtgeomseqs.*, 'sequences') AS mvt
340
+ FROM (
341
+ SELECT
342
+ {sequences_fields}
343
+ FROM sequences s
344
+ WHERE
345
+ {sequences_filter}
346
+ ) mvtgeomseqs
347
+ ) mvtsequences,
348
+ (
349
+ SELECT ST_AsMVT(mvtgeompics.*, 'pictures') AS mvt
350
+ FROM (
351
+ SELECT
352
+ ST_AsMVTGeom(ST_Transform(p.geom, 3857), ST_TileEnvelope(%(z)s, %(x)s, %(y)s)) AS geom,
353
+ p.id, p.ts, p.heading, p.account_id,
354
+ NULLIF(p.status != 'ready' OR s.status != 'ready', FALSE) AS hidden,
355
+ array_to_json(ARRAY_AGG(sp.seq_id)) AS sequences,
356
+ p.metadata->>'type' AS type,
357
+ TRIM(CONCAT(p.metadata->>'make', ' ', p.metadata->>'model')) AS model
358
+ FROM pictures p
359
+ LEFT JOIN sequences_pictures sp ON p.id = sp.pic_id
360
+ LEFT JOIN sequences s ON s.id = sp.seq_id
361
+ WHERE
362
+ {pictures_filter}
363
+ GROUP BY 1, 2, 3, 4, 5, 6
364
+ ) mvtgeompics
365
+ ) mvtpictures
366
+ """
229
367
  ).format(
230
368
  sequences_filter=sql.SQL(" AND ").join(sequences_filter),
231
369
  pictures_filter=sql.SQL(" AND ").join(pictures_filter),
232
370
  sequences_fields=sql.SQL(", ").join(sequences_fields),
233
371
  )
234
372
 
235
- elif z >= 7:
373
+ # Full sequences (z7-14.9 and z0-14.9 for specific users)
374
+ elif z >= ZOOM_GRID_SEQUENCES + 1 or onlyForUser:
236
375
  query = sql.SQL(
237
376
  """
238
- SELECT ST_AsMVT(mvtsequences.*, 'sequences') AS mvt
239
- FROM (
240
- SELECT
241
- {sequences_fields}
242
- FROM sequences s
243
- WHERE
244
- {sequences_filter}
245
- ) mvtsequences
246
- """
377
+ SELECT ST_AsMVT(mvtsequences.*, 'sequences') AS mvt
378
+ FROM (
379
+ SELECT
380
+ {sequences_fields}
381
+ FROM sequences s
382
+ WHERE
383
+ {sequences_filter}
384
+ ) mvtsequences
385
+ """
247
386
  ).format(sequences_filter=sql.SQL(" AND ").join(sequences_filter), sequences_fields=sql.SQL(", ").join(sequences_fields))
248
- else:
387
+
388
+ # Sequences + grid (z6-6.9)
389
+ elif z >= ZOOM_GRID_SEQUENCES:
249
390
  query = sql.SQL(
250
391
  """
251
- SELECT ST_AsMVT(mvtsequences.*, 'sequences') AS mvt
252
- FROM (
253
- SELECT
254
- {sequences_fields}
255
- FROM (
256
- SELECT {simplified_sequence_fields}
257
- FROM sequences s
258
- WHERE
259
- {sequences_filter}
260
- ) s
261
- WHERE geom IS NOT NULL
262
- ) mvtsequences
263
- """
392
+ SELECT mvtsequences.mvt || mvtgrid.mvt
393
+ FROM (
394
+ SELECT ST_AsMVT(mvtgeomseqs.*, 'sequences') AS mvt
395
+ FROM (
396
+ SELECT
397
+ {simplified_sequence_fields}
398
+ FROM sequences s
399
+ WHERE
400
+ {sequences_filter}
401
+ ) mvtgeomseqs
402
+ ) mvtsequences,
403
+ (
404
+ SELECT ST_AsMVT(mvtgeomgrid.*, 'grid') AS mvt
405
+ FROM (
406
+ SELECT
407
+ {grid_fields}
408
+ FROM pictures_grid g
409
+ WHERE {grid_filter}
410
+ ) mvtgeomgrid
411
+ ) mvtgrid
412
+ """
264
413
  ).format(
265
414
  sequences_filter=sql.SQL(" AND ").join(sequences_filter),
266
- sequences_fields=sql.SQL(", ").join(sequences_fields),
267
415
  simplified_sequence_fields=sql.SQL(", ").join(simplified_sequence_fields),
416
+ grid_filter=sql.SQL(" AND ").join(grid_filter),
417
+ grid_fields=sql.SQL(", ").join(grid_fields),
418
+ )
419
+
420
+ # Grid overview (all users + z0-5.9)
421
+ else:
422
+ query = sql.SQL(
423
+ """
424
+ SELECT ST_AsMVT(mvtgrid.*, 'grid') AS mvt
425
+ FROM (
426
+ SELECT
427
+ {grid_fields}
428
+ FROM pictures_grid g
429
+ WHERE {grid_filter}
430
+ ) mvtgrid
431
+ """
432
+ ).format(
433
+ grid_filter=sql.SQL(" AND ").join(grid_filter),
434
+ grid_fields=sql.SQL(", ").join(grid_fields),
268
435
  )
269
436
 
270
437
  return query, params
271
438
 
272
439
 
440
+ @bp.route("/users/<uuid:userId>/map/style.json")
441
+ @user_dependant_response(False)
442
+ def getUserStyle(userId: UUID):
443
+ """Get vector tiles style for a single user.
444
+
445
+ This style file follows MapLibre Style Spec : https://maplibre.org/maplibre-style-spec/
446
+
447
+ ---
448
+ tags:
449
+ - Map
450
+ parameters:
451
+ - name: userId
452
+ in: path
453
+ description: User ID
454
+ required: true
455
+ schema:
456
+ type: string
457
+ responses:
458
+ 200:
459
+ description: Vector tiles style JSON
460
+ content:
461
+ application/json:
462
+ schema:
463
+ $ref: '#/components/schemas/MapLibreStyleJSON'
464
+ """
465
+ return get_style_json(forUser=userId)
466
+
467
+
273
468
  @bp.route("/users/<uuid:userId>/map/<int:z>/<int:x>/<int:y>.<format>")
274
469
  def getUserTile(userId: UUID, z: int, x: int, y: int, format: str):
275
470
  """Get pictures and sequences as vector tiles for a specific user.
@@ -327,6 +522,27 @@ def getUserTile(userId: UUID, z: int, x: int, y: int, format: str):
327
522
  return _getTile(z, x, y, format, onlyForUser=userId, filter=filter)
328
523
 
329
524
 
525
+ @bp.route("/users/me/map/style.json")
526
+ @auth.login_required_with_redirect()
527
+ def getMyStyle(account: Account):
528
+ """Get vector tiles style.
529
+
530
+ This style file follows MapLibre Style Spec : https://maplibre.org/maplibre-style-spec/
531
+
532
+ ---
533
+ tags:
534
+ - Map
535
+ responses:
536
+ 200:
537
+ description: Vector tiles style JSON
538
+ content:
539
+ application/json:
540
+ schema:
541
+ $ref: '#/components/schemas/MapLibreStyleJSON'
542
+ """
543
+ return get_style_json(forUser="me")
544
+
545
+
330
546
  @bp.route("/users/me/map/<int:z>/<int:x>/<int:y>.<format>")
331
547
  @auth.login_required_with_redirect()
332
548
  def getMyTile(account: Account, z: int, x: int, y: int, format: str):
geovisio/web/params.py CHANGED
@@ -6,12 +6,12 @@ from dateutil.parser import parse as dateparser
6
6
  import datetime
7
7
  import re
8
8
  from werkzeug.datastructures import MultiDict
9
- from dataclasses import dataclass, field
10
- from typing import Optional, Tuple, Dict, List, Any
9
+ from typing import Optional, Tuple, Any, List
11
10
  from pygeofilter.backends.sql import to_sql_where
12
11
  from pygeofilter.parsers.ecql import parse as ecql_parser
13
12
  from psycopg import sql
14
- from geovisio.utils.sequences import FieldMapping, SortBy, SortByField, STAC_FIELD_MAPPINGS, STAC_FIELD_TO_SQL_FILTER, SQLDirection
13
+ from geovisio.utils.sequences import STAC_FIELD_MAPPINGS, STAC_FIELD_TO_SQL_FILTER
14
+ from geovisio.utils.fields import SortBy, SQLDirection, SortByField
15
15
 
16
16
 
17
17
  RGX_SORTBY = re.compile("[+-]?[A-Za-z_].*(,[+-]?[A-Za-z_].*)*")
@@ -171,6 +171,82 @@ def parse_bbox(value: Optional[Any], tryFallbacks=True):
171
171
  return None
172
172
 
173
173
 
174
+ def parse_lonlat(values: Optional[Any], paramName: Optional[str] = None) -> Optional[List[float]]:
175
+ """Reads lat,lon query parameter.
176
+
177
+ >>> parse_lonlat('0,0')
178
+ [0.0, 0.0]
179
+ >>> parse_lonlat('47.8,1.25')
180
+ [47.8, 1.25]
181
+ >>> parse_lonlat('-1.57,-12.5')
182
+ [-1.57, -12.5]
183
+ >>> parse_lonlat([42.7, -1.24])
184
+ [42.7, -1.24]
185
+ >>> parse_lonlat("[42.7, -1.24]")
186
+ [42.7, -1.24]
187
+ >>> parse_lonlat(None)
188
+
189
+ >>> parse_lonlat('aaa') # doctest: +IGNORE_EXCEPTION_DETAIL
190
+ Traceback (most recent call last):
191
+ geovisio.errors.InvalidAPIUsage: Parameter must be coordinates in lat,lon format
192
+ >>> parse_lonlat('182,0') # doctest: +IGNORE_EXCEPTION_DETAIL
193
+ Traceback (most recent call last):
194
+ geovisio.errors.InvalidAPIUsage: Longitude in parameter is not valid (should be between -180 and 180)
195
+ >>> parse_lonlat('0,-92') # doctest: +IGNORE_EXCEPTION_DETAIL
196
+ Traceback (most recent call last):
197
+ geovisio.errors.InvalidAPIUsage: Latitude in parameter is not valid (should be between -90 and 90)
198
+ """
199
+
200
+ if values is None or values == []:
201
+ return None
202
+
203
+ entries = parse_list(values, paramName=paramName)
204
+
205
+ if entries is None or len(entries) != 2:
206
+ raise errors.InvalidAPIUsage(f"Parameter {paramName or ''} must be coordinates in lat,lon format", status_code=400)
207
+
208
+ return [
209
+ as_longitude(entries[0], f"Longitude in parameter {paramName or ''} is not valid (should be between -180 and 180)"),
210
+ as_latitude(entries[1], f"Latitude in parameter {paramName or ''} is not valid (should be between -90 and 90)"),
211
+ ]
212
+
213
+
214
+ def parse_distance_range(values: Optional[str], paramName: Optional[str] = None):
215
+ """Reads distance range query parameter.
216
+
217
+ >>> parse_distance_range('3-12')
218
+ [3, 12]
219
+ >>> parse_distance_range(None)
220
+
221
+ >>> parse_distance_range('12-3') # doctest: +IGNORE_EXCEPTION_DETAIL
222
+ Traceback (most recent call last):
223
+ geovisio.errors.InvalidAPIUsage: Parameter has a min value greater than its max value
224
+ >>> parse_distance_range('12') # doctest: +IGNORE_EXCEPTION_DETAIL
225
+ Traceback (most recent call last):
226
+ geovisio.errors.InvalidAPIUsage: Parameter is invalid (should be a distance range in meters like "5-15")
227
+ """
228
+
229
+ if values is not None:
230
+ dists = values.split("-")
231
+ if len(dists) != 2:
232
+ raise errors.InvalidAPIUsage(
233
+ f"Parameter {paramName or ''} is invalid (should be a distance range in meters like \"5-15\")", status_code=400
234
+ )
235
+ try:
236
+ dists = [int(d) for d in dists]
237
+ if dists[0] > dists[1]:
238
+ raise errors.InvalidAPIUsage(f"Parameter {paramName or ''} has a min value greater than its max value", status_code=400)
239
+ else:
240
+ return dists
241
+
242
+ except ValueError:
243
+ raise errors.InvalidAPIUsage(
244
+ f"Parameter {paramName or ''} is invalid (should be a distance range in meters like \"5-15\")", status_code=400
245
+ )
246
+ else:
247
+ return None
248
+
249
+
174
250
  def parse_list(value: Optional[Any], tryFallbacks: bool = True, paramName: Optional[str] = None):
175
251
  """Reads STAC query parameters that are structured like lists.
176
252
 
@@ -192,6 +268,8 @@ def parse_list(value: Optional[Any], tryFallbacks: bool = True, paramName: Optio
192
268
  ['a', 'b']
193
269
  >>> parse_list("['a', 'b']")
194
270
  ['a', 'b']
271
+ >>> parse_list([42, 10])
272
+ [42, 10]
195
273
  >>> parse_list([])
196
274
 
197
275
  >>> parse_list(MultiDict([('collections', 'a'), ('collections', 'b')]))
@@ -235,7 +313,7 @@ def parse_list(value: Optional[Any], tryFallbacks: bool = True, paramName: Optio
235
313
  if len(res) == 0:
236
314
  return None
237
315
  else:
238
- return [n.strip('"').strip("'") for n in res]
316
+ return [n.strip('"').strip("'") if isinstance(n, str) else n for n in res]
239
317
 
240
318
  else:
241
319
  return None
geovisio/web/stac.py CHANGED
@@ -4,7 +4,7 @@ from flask import Blueprint, current_app, request, url_for
4
4
  from geovisio import errors
5
5
  from geovisio.utils import auth
6
6
  from psycopg.rows import dict_row
7
- from geovisio.utils.fields import Bounds
7
+ from geovisio.utils.fields import SortBy, SQLDirection, Bounds, SortByField
8
8
  from geovisio.web.utils import (
9
9
  STAC_VERSION,
10
10
  cleanNoneInList,
@@ -18,9 +18,6 @@ from geovisio.web.utils import (
18
18
  from geovisio.utils.sequences import (
19
19
  get_collections,
20
20
  CollectionsRequest,
21
- SortBy,
22
- SortByField,
23
- SQLDirection,
24
21
  STAC_FIELD_MAPPINGS,
25
22
  get_pagination_links,
26
23
  )
@@ -122,6 +119,7 @@ def getLanding():
122
119
  .replace("333", "{z}")
123
120
  .replace("bob", "{userId}")
124
121
  )
122
+ userStyleUrl = url_for("map.getUserStyle", userId="bob", _external=True).replace("bob", "{userId}")
125
123
 
126
124
  if "stac_extensions" not in catalog:
127
125
  catalog["stac_extensions"] = []
@@ -146,12 +144,24 @@ def getLanding():
146
144
  "href": mapUrl,
147
145
  "title": "Pictures and sequences vector tiles",
148
146
  },
147
+ {
148
+ "rel": "xyz-style",
149
+ "type": "application/json",
150
+ "href": url_for("map.getStyle", _external=True),
151
+ "title": "MapLibre Style JSON",
152
+ },
149
153
  {
150
154
  "rel": "user-xyz",
151
155
  "type": "application/vnd.mapbox-vector-tile",
152
156
  "href": userMapUrl,
153
157
  "title": "Pictures and sequences vector tiles for a given user",
154
158
  },
159
+ {
160
+ "rel": "user-xyz-style",
161
+ "type": "application/json",
162
+ "href": userStyleUrl,
163
+ "title": "MapLibre Style JSON",
164
+ },
155
165
  {
156
166
  "rel": "collection-preview",
157
167
  "type": "image/jpeg",