Skip to content

Commit 72827e6

Browse files
committed
feat(geoapi): add support for polygon label anchors in PMTiles generation
1 parent 8c6b0c9 commit 72827e6

File tree

6 files changed

+298
-68
lines changed

6 files changed

+298
-68
lines changed

apps/geoapi/src/geoapi/routers/tiles.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ async def get_tile(
162162
limit=limit,
163163
columns=columns,
164164
geometry_column=geometry_column,
165+
geometry_type=metadata.geometry_type,
165166
)
166167
except TimeoutError:
167168
# Query exceeded timeout - return 504 Gateway Timeout

apps/geoapi/src/geoapi/services/tile_service.py

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,7 @@ async def get_tile(
10171017
limit: Optional[int] = None,
10181018
columns: Optional[list[dict]] = None,
10191019
geometry_column: str = "geometry",
1020+
geometry_type: Optional[str] = None,
10201021
) -> Optional[tuple[bytes, bool, str]]:
10211022
"""Generate MVT tile for a layer.
10221023
@@ -1031,6 +1032,7 @@ async def get_tile(
10311032
limit: Maximum features
10321033
columns: List of column dicts with 'name' and 'type' keys
10331034
geometry_column: Name of the geometry column
1035+
geometry_type: Geometry type (e.g., "polygon") for anchor generation
10341036
10351037
Returns:
10361038
Tuple of (MVT tile bytes, is_gzip_compressed, source) or None if empty
@@ -1053,6 +1055,7 @@ async def get_tile(
10531055
limit=limit,
10541056
columns=columns,
10551057
geometry_column=geometry_column,
1058+
geometry_type=geometry_type,
10561059
),
10571060
)
10581061
if tile_data is None:
@@ -1084,6 +1087,7 @@ async def get_tile(
10841087
limit=limit,
10851088
columns=columns,
10861089
geometry_column=geometry_column,
1090+
geometry_type=geometry_type,
10871091
),
10881092
)
10891093
if tile_data is None:
@@ -1117,9 +1121,14 @@ def _generate_dynamic_tile(
11171121
limit: Optional[int] = None,
11181122
columns: Optional[list[dict]] = None,
11191123
geometry_column: str = "geometry",
1124+
geometry_type: Optional[str] = None,
11201125
) -> Optional[bytes]:
11211126
"""Generate MVT tile dynamically using DuckDB.
11221127
1128+
For polygon layers, generates two MVT layers in the same tile:
1129+
- 'default': the polygon geometry
1130+
- 'default_anchor': point features from ST_PointOnSurface for label placement
1131+
11231132
Args:
11241133
layer_info: Layer information from URL
11251134
z, x, y: Tile coordinates
@@ -1129,6 +1138,7 @@ def _generate_dynamic_tile(
11291138
limit: Maximum features
11301139
columns: List of column dicts
11311140
geometry_column: Name of the geometry column
1141+
geometry_type: Geometry type (e.g., "polygon") for anchor generation
11321142
11331143
Returns:
11341144
MVT tile bytes or None if empty
@@ -1350,6 +1360,9 @@ def get_cast_type(col_type: str) -> str | None:
13501360
FROM candidates, bounds
13511361
"""
13521362

1363+
# Determine if we need to generate label anchor points for polygon layers
1364+
is_polygon_layer = geometry_type and "polygon" in geometry_type.lower()
1365+
13531366
try:
13541367
# Use pool's execute_with_retry for automatic connection handling
13551368
# Apply query timeout to prevent blocking other requests
@@ -1361,16 +1374,100 @@ def get_cast_type(col_type: str) -> str | None:
13611374
timeout=settings.QUERY_TIMEOUT,
13621375
)
13631376

1364-
if result and result[0]:
1365-
return bytes(result[0])
1366-
return None
1377+
if not result or not result[0]:
1378+
return None
1379+
1380+
tile_data = bytes(result[0])
1381+
1382+
# For polygon layers, generate and append a label anchor layer
1383+
# using ST_PointOnSurface for optimal label placement
1384+
if is_polygon_layer:
1385+
anchor_data = self._generate_anchor_layer(
1386+
query=query,
1387+
struct_pack_args=struct_pack_args,
1388+
geom_col=geom_col,
1389+
params=params,
1390+
)
1391+
if anchor_data:
1392+
# MVT is protobuf — multiple layers can be concatenated
1393+
tile_data = tile_data + anchor_data
1394+
1395+
return tile_data
13671396
except TimeoutError:
13681397
logger.warning("Tile query timeout: z=%d, x=%d, y=%d", z, x, y)
13691398
raise
13701399
except Exception as e:
13711400
logger.error("Tile generation error: %s", e)
13721401
raise
13731402

1403+
def _generate_anchor_layer(
1404+
self,
1405+
query: str,
1406+
struct_pack_args: str,
1407+
geom_col: str,
1408+
params: list | None = None,
1409+
) -> Optional[bytes]:
1410+
"""Generate a label anchor MVT layer for polygon features.
1411+
1412+
Creates point features using ST_PointOnSurface for optimal label
1413+
placement inside polygons. The result is a separate MVT layer named
1414+
'default_anchor' that can be concatenated with the main tile.
1415+
1416+
Args:
1417+
query: The original tile query (reused for the candidates CTE)
1418+
struct_pack_args: Original struct_pack arguments
1419+
geom_col: Geometry column name
1420+
params: Query parameters
1421+
1422+
Returns:
1423+
MVT bytes for the anchor layer, or None if empty
1424+
"""
1425+
# Build anchor struct_pack by wrapping the transformed geometry with
1426+
# ST_PointOnSurface. The original geometry field looks like:
1427+
# geometry := ST_AsMVTGeom(ST_Transform(...), ST_Extent(bounds.bbox3857))
1428+
# We need:
1429+
# geometry := ST_AsMVTGeom(
1430+
# ST_PointOnSurface(ST_Transform(...)),
1431+
# ST_Extent(bounds.bbox3857)
1432+
# )
1433+
original_geom_expr = (
1434+
f'ST_AsMVTGeom(ST_Transform(candidates."{geom_col}"'
1435+
)
1436+
anchor_geom_expr = (
1437+
f'ST_AsMVTGeom(ST_PointOnSurface(ST_Transform(candidates."{geom_col}"'
1438+
)
1439+
# The closing parens: original has ...always_xy := true), ST_Extent(bounds.bbox3857))
1440+
# We need an extra ) to close ST_PointOnSurface before the comma
1441+
original_close = "always_xy := true), ST_Extent"
1442+
anchor_close = "always_xy := true)), ST_Extent"
1443+
1444+
anchor_struct = struct_pack_args.replace(
1445+
original_geom_expr, anchor_geom_expr
1446+
).replace(
1447+
original_close, anchor_close
1448+
)
1449+
anchor_query = query.replace(
1450+
f"struct_pack({struct_pack_args})",
1451+
f"struct_pack({anchor_struct})",
1452+
).replace(
1453+
"'default'",
1454+
"'default_anchor'",
1455+
)
1456+
1457+
try:
1458+
result = ducklake_pool.execute_with_retry(
1459+
anchor_query,
1460+
params=params if params else None,
1461+
max_retries=1,
1462+
fetch_all=False,
1463+
timeout=settings.QUERY_TIMEOUT,
1464+
)
1465+
if result and result[0]:
1466+
return bytes(result[0])
1467+
except Exception as e:
1468+
logger.warning("Anchor layer generation failed: %s", e)
1469+
return None
1470+
13741471

13751472
# Singleton instance
13761473
tile_service = TileService()

apps/web/components/map/Layers.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,9 @@ const Layers = (props: LayersProps) => {
250250
id={
251251
layer.properties?.["custom_marker"] ? layer.id.toString() : `text-label-${layer.id}`
252252
}
253-
source-layer="default"
253+
source-layer={
254+
layer.feature_layer_geometry_type === "polygon" ? "default_anchor" : "default"
255+
}
254256
minzoom={layer.properties.min_zoom || 0}
255257
maxzoom={layer.properties.max_zoom || 24}
256258
{...labelStyleSpec}

0 commit comments

Comments
 (0)