@@ -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
13761473tile_service = TileService ()
0 commit comments