Skip to content

Commit 56dfb56

Browse files
committed
Merge branch 'main' of github.com:UW-Macrostrat/macrostrat
2 parents 5b6e515 + bbddba3 commit 56dfb56

File tree

6 files changed

+216
-4
lines changed

6 files changed

+216
-4
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import api.routes.security
1212
from api.database import connect_engine, dispose_engine
13+
from api.map import router as map_router
1314
from api.match import router as match_router
1415
from api.routes.ingest import router as ingest_router
1516
from api.routes.object import router as object_router
@@ -48,6 +49,7 @@ async def setup_engine(app: FastAPI):
4849
app.include_router(object_router)
4950
app.include_router(ingest_router)
5051
app.include_router(sources_router)
52+
app.include_router(map_router, prefix="/map")
5153

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