Skip to content

Commit 9131ffd

Browse files
committed
Merge branch 'legend-api'
* legend-api: Created a development legend API
2 parents a68d3aa + 4c508c7 commit 9131ffd

File tree

6 files changed

+216
-5
lines changed

6 files changed

+216
-5
lines changed

services/api-v3/Makefile

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
all:
2-
poetry lock
3-
poetry install
4-
poetry run uvicorn api.app:app \
2+
uv sync
3+
uv run uvicorn api.app:app \
54
--reload \
65
--log-level debug \
76
--reload-dir ../..
@@ -18,4 +17,4 @@ release:
1817
# Ensure that the repository is clean
1918
git diff-index --quiet HEAD --
2019
git tag -a $(TAG) -m "Version $(TAG)"
21-
git push origin $(TAG)
20+
git push origin $(TAG)

services/api-v3/api/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
import dotenv
44
import uvicorn
5-
from api.routes.dev_routes.convert import convert_router
65
from fastapi import FastAPI
76
from fastapi.middleware.cors import CORSMiddleware
87

8+
from api.routes.dev_routes.convert import convert_router
9+
910
dotenv.load_dotenv()
1011

1112
import api.routes.security
@@ -14,6 +15,7 @@
1415
from api.routes.ingest import router as ingest_router
1516
from api.routes.object import router as object_router
1617
from api.routes.sources import router as sources_router
18+
from api.map import router as map_router
1719

1820

1921
@asynccontextmanager
@@ -48,6 +50,7 @@ async def setup_engine(app: FastAPI):
4850
app.include_router(object_router)
4951
app.include_router(ingest_router)
5052
app.include_router(sources_router)
53+
app.include_router(map_router, prefix="/map")
5154

5255
app.include_router(convert_router, prefix="/dev")
5356
app.include_router(match_router, prefix="/dev/match")
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from typing import Annotated
2+
3+
import morecantile
4+
from fastapi import APIRouter, HTTPException
5+
from fastapi import Depends
6+
from morecantile import Tile
7+
from shapely import GEOSException
8+
from shapely.geometry import Polygon
9+
from shapely.wkb import loads as load_wkb
10+
from shapely.wkt import loads as load_wkt
11+
12+
from api.database import get_sync_database
13+
14+
router = APIRouter(tags=["map"])
15+
16+
tms = morecantile.tms.get("WebMercatorQuad")
17+
18+
19+
class MapAreaInfo:
20+
def __init__(self, bounds: Polygon, zoom: int):
21+
self.bounds = bounds
22+
self.zoom = zoom
23+
24+
25+
def map_area_params(
26+
bounds: str = None, lat: float = None, lng: float = None, zoom: int = None
27+
) -> MapAreaInfo:
28+
"""Dependency to get map area information."""
29+
30+
if bounds is not None:
31+
_bounds = parse_bounds(bounds)
32+
zoom = min_bounding_tile(_bounds).z + 1
33+
return MapAreaInfo(bounds=_bounds, zoom=zoom)
34+
35+
if lat is None or lng is None:
36+
raise HTTPException(400, "Either bounds or and lat,lng,zoom must be provided.")
37+
if zoom is None:
38+
zoom = 23
39+
tile = tms.tile(lng=lng, lat=lat, zoom=zoom)
40+
_bounds = tile_polygon(tile)
41+
return MapAreaInfo(bounds=_bounds, zoom=zoom)
42+
43+
# Calculate bounds from lat, lng, zoom
44+
45+
46+
def tile_polygon(tile: Tile) -> Polygon:
47+
"""Get the polygon for a given tile."""
48+
bbox = tms.bounds(tile)
49+
return Polygon.from_bounds(bbox.left, bbox.bottom, bbox.right, bbox.top)
50+
51+
52+
def min_bounding_tile(geometry: Polygon) -> Tile:
53+
"""Get the bounding tile for a given geometry."""
54+
minx, miny, maxx, maxy = geometry.bounds
55+
z = 0
56+
for z in range(0, 23):
57+
ul_tile = tms.tile(lng=minx, lat=maxy, zoom=z)
58+
lr_tile = tms.tile(lng=maxx, lat=miny, zoom=z)
59+
if ul_tile != lr_tile:
60+
break
61+
return tms.tile(lng=minx, lat=maxy, zoom=z - 1)
62+
63+
64+
def parse_bounds(bounds: str) -> Polygon:
65+
"""Parse bounds string into a tuple of floats."""
66+
67+
try:
68+
# Check if bounds is a WKB hex string
69+
return load_wkb(bounds, hex=True)
70+
except GEOSException:
71+
pass
72+
73+
try:
74+
# Check if bounds is a WKT POLYGON
75+
return load_wkt(bounds)
76+
except GEOSException:
77+
pass
78+
79+
# Try to parse as comma-separated values
80+
try:
81+
west, south, east, north = map(float, bounds.split(","))
82+
return Polygon.from_bounds(west, south, east, north)
83+
except ValueError:
84+
raise HTTPException(
85+
400,
86+
"Invalid bounds format. Expected WKB hex, WKT POLYGON, or comma-separated values (west,south,east,north).",
87+
)
88+
89+
90+
def scale_for_zoom(z: int, dz: int = 0):
91+
_z = z - dz
92+
if _z < 3:
93+
return "tiny"
94+
elif _z < 6:
95+
return "small"
96+
elif _z < 9:
97+
return "medium"
98+
else:
99+
return "large"
100+
101+
102+
def get_compilation(compilation: str) -> str:
103+
"""Dependency to validate compilation parameter."""
104+
valid_compilations = ["carto"]
105+
if compilation not in valid_compilations:
106+
raise ValueError(
107+
f"Invalid compilation '{compilation}'. Valid options are: {valid_compilations}"
108+
)
109+
return compilation
110+
111+
112+
@router.get(
113+
"/{compilation}/legend",
114+
summary="Get map service status",
115+
)
116+
def get_map_legend(
117+
compilation: Annotated[str, Depends(get_compilation)],
118+
map_area: Annotated[MapAreaInfo, Depends(map_area_params)],
119+
):
120+
"""Get the legend for a given map compilation."""
121+
122+
db = get_sync_database()
123+
124+
scale = scale_for_zoom(map_area.zoom)
125+
126+
if compilation != "carto":
127+
raise HTTPException(
128+
status_code=400,
129+
detail="Only 'carto' compilation is currently supported.",
130+
)
131+
132+
res = db.run_query(
133+
"""
134+
WITH polygons AS (
135+
SELECT legend_id, source_id, scale
136+
FROM carto.polygons p
137+
JOIN maps.map_legend ml
138+
USING (map_id)
139+
WHERE p.scale = :scale
140+
AND st_intersects(p.geom, ST_SetSRID(ST_GeomFromText(:bounds), 4326))
141+
GROUP BY legend_id, source_id, scale
142+
)
143+
SELECT legend_id,
144+
m.source_id,
145+
s.scale,
146+
REGEXP_REPLACE(m.name, E'[\\n\\r\\f\\u000B\\u0085\\u2028\\u2029]+', ' ', 'g') AS map_unit_name,
147+
REGEXP_REPLACE(strat_name, E'[\\n\\r\\f\\u000B\\u0085\\u2028\\u2029]+', ' ', 'g') AS strat_name,
148+
age,
149+
REGEXP_REPLACE(lith, E'[\\n\\r\\f\\u000B\\u0085\\u2028\\u2029]+', ' ', 'g') AS lith,
150+
REGEXP_REPLACE(descrip, E'[\\n\\r\\f\\u000B\\u0085\\u2028\\u2029]+', ' ', 'g') AS descrip,
151+
REGEXP_REPLACE(comments, E'[\\n\\r\\f\\u000B\\u0085\\u2028\\u2029]+', ' ', 'g') AS comments,
152+
best_age_top::float t_age,
153+
best_age_bottom::float b_age,
154+
m.b_interval,
155+
m.t_interval,
156+
strat_name_ids strat_name_id,
157+
unit_ids unit_id,
158+
lith_classes,
159+
lith_types,
160+
lith_ids lith_id,
161+
color,
162+
m.area::float,
163+
tiny_area::float,
164+
small_area::float,
165+
medium_area::float,
166+
large_area::float
167+
FROM maps.legend m
168+
JOIN maps.sources s USING (source_id)
169+
JOIN polygons p USING (legend_id, source_id)
170+
""",
171+
params={"bounds": map_area.bounds.wkt, "scale": scale},
172+
).mappings().all()
173+
174+
return res

services/api-v3/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"python-slugify>=8.0.4,<9",
2626
"macrostrat.database>=3.0.0,<4",
2727
"macrostrat.match-utils",
28+
"morecantile>=7.0.2",
2829
]
2930

3031
[dependency-groups]

services/api-v3/uv.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

uv.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)