Skip to content

Commit 4175700

Browse files
committed
feat(geoapi): add label parameter to get_feature_tile_url for polygon layers
1 parent 36866b9 commit 4175700

File tree

3 files changed

+50
-27
lines changed

3 files changed

+50
-27
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ async def get_tile(
5656
cql_filter: CqlFilterDep = None,
5757
bbox: BBoxDep = None,
5858
limit: int = Query(default=None, ge=1, le=100000, description="Max features"),
59+
label: bool = Query(default=False, description="Merge polygon label anchor points into tile response"),
5960
) -> Response:
6061
"""Get a vector tile for the specified collection and tile coordinates."""
6162
request_id = str(uuid.uuid4())[:8]
@@ -78,6 +79,7 @@ async def get_tile(
7879
z=z,
7980
x=x,
8081
y=y,
82+
label=label,
8183
)
8284
if result is not None:
8385
tile_data, is_gzip, source = result
@@ -120,6 +122,7 @@ async def get_tile(
120122
z=z,
121123
x=x,
122124
y=y,
125+
label=label,
123126
)
124127
if result is not None:
125128
tile_data, is_gzip, source = result

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

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ async def get_tile_from_pmtiles_only(
485485
z: int,
486486
x: int,
487487
y: int,
488+
label: bool = False,
488489
) -> Optional[tuple[bytes, bool, str]]:
489490
"""Get tile directly from PMTiles without metadata lookup.
490491
@@ -495,14 +496,15 @@ async def get_tile_from_pmtiles_only(
495496
z: Zoom level
496497
x: Tile X coordinate
497498
y: Tile Y coordinate
499+
label: If True, serve anchor PMTiles (polygon label points) instead of main tiles
498500
499501
Returns:
500502
Tuple of (tile_data, is_gzip, source) or None if PMTiles not available
501503
"""
502504
if not self._pmtiles_exists(layer_info):
503505
return None
504506

505-
result = await self._get_tile_from_pmtiles(layer_info, z, x, y)
507+
result = await self._get_tile_from_pmtiles(layer_info, z, x, y, label=label)
506508
if result is None:
507509
return None
508510

@@ -540,6 +542,7 @@ async def get_tile_from_pmtiles_by_layer_id(
540542
z: int,
541543
x: int,
542544
y: int,
545+
label: bool = False,
543546
) -> Optional[tuple[bytes, bool, str]]:
544547
"""Get tile directly from PMTiles using only layer_id (no schema lookup).
545548
@@ -551,20 +554,24 @@ async def get_tile_from_pmtiles_by_layer_id(
551554
z: Zoom level
552555
x: Tile X coordinate
553556
y: Tile Y coordinate
557+
label: If True, serve anchor PMTiles (polygon label points) instead of main tiles
554558
555559
Returns:
556560
Tuple of (tile_data, is_gzip, source) or None if PMTiles not available
557561
"""
558562
start_time = time.monotonic()
559563

564+
# Anchor tiles are cached under a separate key to avoid collision
565+
cache_layer_id = layer_id + "_anchor" if label else layer_id
566+
560567
# Check Redis cache first (fast path for distributed deployments)
561-
cached = get_cached_tile(layer_id, z, x, y)
568+
cached = get_cached_tile(cache_layer_id, z, x, y)
562569
if cached is not None:
563570
tile_data, is_gzip = cached
564571
elapsed_ms = (time.monotonic() - start_time) * 1000
565572
logger.debug(
566573
"Redis cache hit for %s tile %d/%d/%d: %d bytes (%.1fms)",
567-
layer_id[:8],
574+
cache_layer_id[:8],
568575
z,
569576
x,
570577
y,
@@ -595,14 +602,15 @@ async def get_tile_from_pmtiles_by_layer_id(
595602

596603
tile_data, is_gzip = result
597604

598-
# Step 3: For polygon layers, also read from anchor PMTiles and concatenate
599-
tile_data, is_gzip = await self._concat_anchor_tile(
600-
pmtiles_path, z, x, y, tile_data, is_gzip
601-
)
605+
# Step 3: When label=True, merge anchor tile (polygon label points)
606+
if label:
607+
tile_data, is_gzip = await self._merge_anchor_tile(
608+
pmtiles_path, z, x, y, tile_data, is_gzip
609+
)
602610

603611
# Cache in Redis for other pods
604612
if tile_data:
605-
cache_tile(layer_id, z, x, y, tile_data, is_gzip)
613+
cache_tile(cache_layer_id, z, x, y, tile_data, is_gzip)
606614

607615
logger.info(
608616
"PMTiles %s tile %d/%d/%d: %d bytes (%.1fms)",
@@ -769,7 +777,7 @@ def _read_tile() -> (
769777
return result
770778

771779
async def _get_tile_from_pmtiles(
772-
self, layer_info: LayerInfo, z: int, x: int, y: int
780+
self, layer_info: LayerInfo, z: int, x: int, y: int, label: bool = False
773781
) -> Optional[tuple[bytes, bool]]:
774782
"""Get tile data from PMTiles file using pmtiles library.
775783
@@ -784,6 +792,7 @@ async def _get_tile_from_pmtiles(
784792
z: Zoom level
785793
x: Tile X coordinate
786794
y: Tile Y coordinate
795+
label: If True, serve anchor PMTiles (polygon label points) instead of main tiles
787796
788797
Returns:
789798
Tuple of (tile_data, is_gzip_compressed) or None if tile not found
@@ -907,14 +916,17 @@ def _read_tile() -> (
907916
else:
908917
result = b"", False
909918

910-
# Concatenate anchor tile for polygon layers (separate anchor PMTiles)
911919
tile_data, is_gzip = result
912-
tile_data, is_gzip = await self._concat_anchor_tile(
913-
pmtiles_path, z, x, y, tile_data, is_gzip
914-
)
920+
921+
# Merge anchor tile when labels are requested
922+
if label:
923+
tile_data, is_gzip = await self._merge_anchor_tile(
924+
pmtiles_path, z, x, y, tile_data, is_gzip
925+
)
926+
915927
return tile_data, is_gzip
916928

917-
async def _concat_anchor_tile(
929+
async def _merge_anchor_tile(
918930
self,
919931
pmtiles_path: Path,
920932
z: int,
@@ -923,11 +935,10 @@ async def _concat_anchor_tile(
923935
tile_data: bytes,
924936
is_gzip: bool,
925937
) -> tuple[bytes, bool]:
926-
"""Concatenate anchor tile data for polygon layers.
938+
"""Merge anchor tile data into the main tile for polygon label support.
927939
928-
Checks if a separate anchor PMTiles file exists (containing label
929-
anchor points from --convert-polygons-to-label-points). If so, reads
930-
the anchor tile and concatenates the raw MVT bytes with the main tile.
940+
Reads the anchor PMTiles file (label points from --convert-polygons-to-label-points)
941+
and concatenates the raw MVT bytes with the main tile. Only called when label=True.
931942
932943
Args:
933944
pmtiles_path: Path to the main PMTiles file
@@ -936,28 +947,25 @@ async def _concat_anchor_tile(
936947
is_gzip: Whether tile_data is gzip compressed
937948
938949
Returns:
939-
Tuple of (concatenated_tile_data, is_gzip)
950+
Tuple of (merged_tile_data, is_gzip)
940951
"""
941952
if not tile_data:
942953
return tile_data, is_gzip
943954

944-
# Derive anchor path: t_{layer_id}.pmtiles -> t_{layer_id}_anchor.pmtiles
945955
anchor_path = pmtiles_path.with_name(pmtiles_path.stem + "_anchor.pmtiles")
946956
if not anchor_path.exists():
947957
return tile_data, is_gzip
948958

949-
# Read anchor tile (has its own variable-depth pyramid + overzoom)
950959
anchor_result = await self._get_tile_from_pmtiles_path(anchor_path, z, x, y)
951960
if not anchor_result or not anchor_result[0]:
952961
return tile_data, is_gzip
953962

954963
anchor_data, anchor_is_gzip = anchor_result
955964

956-
# Decompress both if needed — MVT concatenation requires raw protobuf
965+
# MVT concatenation requires raw protobuf bytes
957966
main_bytes = gzip.decompress(tile_data) if is_gzip else tile_data
958967
anchor_bytes = gzip.decompress(anchor_data) if anchor_is_gzip else anchor_data
959968

960-
# MVT is protobuf — multiple layers can be concatenated at the byte level
961969
return main_bytes + anchor_bytes, False
962970

963971
async def _overzoom_tile(

apps/web/components/map/Layers.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,18 @@ const Layers = (props: LayersProps) => {
108108
return extendedFilter;
109109
};
110110

111-
const getFeatureTileUrl = (layer: ProjectLayer | Layer) => {
111+
const getFeatureTileUrl = (layer: ProjectLayer | Layer, label = false) => {
112112
const extendedQuery = getLayerQueryFilter(layer);
113-
let query = "";
113+
const params = new URLSearchParams();
114114

115115
if (extendedQuery && Object.keys(extendedQuery).length > 0) {
116-
query = `?filter=${encodeURIComponent(JSON.stringify(extendedQuery))}`;
116+
params.set("filter", JSON.stringify(extendedQuery));
117+
}
118+
if (label) {
119+
params.set("label", "true");
117120
}
118121

122+
const query = params.size > 0 ? `?${params.toString()}` : "";
119123
const layerId = layer["layer_id"] || layer["id"];
120124
return `${GEOAPI_BASE_URL}/collections/${layerId}/tiles/WebMercatorQuad/{z}/{x}/{y}${query}`;
121125
};
@@ -215,8 +219,16 @@ const Layers = (props: LayersProps) => {
215219
);
216220
const mapLabelFilter = getMapLayerFilter(labelFilter);
217221

222+
const needsLabel =
223+
layer.feature_layer_geometry_type === "polygon" &&
224+
!!(layer.properties as FeatureLayerProperties)?.text_label;
225+
218226
return (
219-
<Source key={layer.id} type="vector" tiles={[getFeatureTileUrl(layer)]} maxzoom={14}>
227+
<Source
228+
key={layer.id}
229+
type="vector"
230+
tiles={[getFeatureTileUrl(layer, needsLabel)]}
231+
maxzoom={14}>
220232
{!layer.properties?.["custom_marker"] && (
221233
<MapLayer
222234
key={getLayerKey(layer)}

0 commit comments

Comments
 (0)