Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin
* `connect_to_duckdb` / `close_duckdb_connection` to manage the connection (`app.state.duckdb_connection`)
* `duck_get_collection_index(*, connection, settings=None)` to list the collections
* `DuckDBSettings` / `DuckDBCatalogSettings` (`TIPG_DUCKDB_` env prefix)
* fix: expand the tile envelope filter by the tile buffer (`TIPG_TILE_BUFFER` pixels, within the TileMatrixSet bounds), so features just outside of a tile but inside its buffer are encoded in the tile (avoids clipped lines/polygons and missing labels at tile edges). Adds `tile_buffer` and `tile_resolution` options to `Collection.cql_where`
* change: add `Collection._tag_geometry_literal` and `Collection._reproject_geometry_literal` abstract methods, defining how CQL2/bbox geometry literals are tagged with, or reprojected to, the geometry column CRS for each backend **breaking change** (custom `Collection` subclasses must implement them)
* change: move `_quote_ident` from `tipg.pg.collections` to `tipg.collections`
* docs: update the `examples/non_app_usage.ipynb` notebook for the new `tipg.pg` API
Expand Down
3 changes: 2 additions & 1 deletion tests/duck/test_geoparquet.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,4 +540,5 @@ def test_projjson_crs(projjson_collection):
tms = morecantile.tms.get("WebMercatorQuad")
content = asyncio.run(col.get_tile(tms=tms, tile=tms.tile(13.5, 47.5, 8)))
decoded = mapbox_vector_tile.decode(content)
assert len(decoded["default"]["features"]) == 1
# (13.5, 47.5) in the tile, (13.0, 47.0) and (14.0, 48.0) in its buffer
assert len(decoded["default"]["features"]) == 3
83 changes: 83 additions & 0 deletions tests/duck/test_tile_buffer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Test the tile envelope filter includes the tile buffer."""

import asyncio

import mapbox_vector_tile
import morecantile
import pytest

from tipg.duck import DuckDBCatalogSettings, DuckDBSettings
from tipg.duck.collections import _collection_index
from tipg.duck.database import create_connection
from tipg.settings import MVTSettings

duckdb = pytest.importorskip("duckdb")

tms = morecantile.tms.get("WebMercatorQuad")

# z4 tile covering lon [0, 22.5], lat [0, ~21.94]
TILE = morecantile.Tile(8, 7, 4)
# tile buffer (256px of 4096px) is 1.40625° wide on this tile
POINTS = {
1: (10.0, 10.0), # inside the tile
2: (23.0, 10.0), # outside the tile, inside its buffer
3: (25.0, 10.0), # outside the tile buffer
}


@pytest.fixture
def points():
"""Collection of points around the tile east edge."""
conn = create_connection(DuckDBSettings())
values = ", ".join(f"({i}, ST_Point({x}, {y}))" for i, (x, y) in POINTS.items())
conn.execute(
f"CREATE VIEW memory.main.points AS SELECT * FROM (VALUES {values}) t(id, geom)"
)
(col,) = _collection_index(conn, DuckDBCatalogSettings(tables=["main.points"]))
yield col
conn.close()


def test_tile_includes_buffer(points):
"""Features in the tile buffer are encoded in the tile."""
assert MVTSettings().tile_buffer == 256

content = asyncio.run(points.get_tile(tms=tms, tile=TILE))
layer = mapbox_vector_tile.decode(content)["default"]
ids = sorted(f["properties"]["id"] for f in layer["features"])
assert ids == [1, 2]


def test_tile_filter_buffer(points):
"""The tile envelope is expanded by `tile_buffer` pixels."""
bounds = tms.xy_bounds(TILE)
margin = (bounds.right - bounds.left) * 256 / 4096

def envelope(where):
((w, s), (e, _), (_, n), *_) = where.to_json()["args"][1]["coordinates"][0]
return w, s, e, n

# no buffer by default
w, s, e, n = envelope(points.cql_where(tile=TILE, tms=tms))
assert (w, s, e, n) == pytest.approx(
tms.lnglat(bounds.left, bounds.bottom) + tms.lnglat(bounds.right, bounds.top)
)

w, s, e, n = envelope(
points.cql_where(tile=TILE, tms=tms, tile_buffer=256, tile_resolution=4096)
)
expected = tms.lnglat(bounds.left - margin, bounds.bottom - margin) + tms.lnglat(
bounds.right + margin, bounds.top + margin
)
assert (w, s, e, n) == pytest.approx(expected)


def test_tile_filter_buffer_clamped(points):
"""The expanded tile envelope stays inside the TMS bounds."""
where = points.cql_where(
tile=morecantile.Tile(0, 0, 0), tms=tms, tile_buffer=256, tile_resolution=4096
)
coords = where.to_json()["args"][1]["coordinates"][0]
xs, ys = [c[0] for c in coords], [c[1] for c in coords]
assert min(xs) >= -180 and max(xs) <= 180
assert min(ys) >= -85.06 and max(ys) <= 85.06
22 changes: 21 additions & 1 deletion tipg/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,8 +450,15 @@ def cql_where( # noqa: C901
dt: str | None = None,
tile: Tile | None = None,
tms: TileMatrixSet | None = None,
tile_buffer: int = 0,
tile_resolution: int = 4096,
) -> Expr | None:
"""Construct WHERE query as a cql2 Expr."""
"""Construct WHERE query as a cql2 Expr.

The `tile` envelope filter is expanded by `tile_buffer` pixels (of a
`tile_resolution` pixels tile), so features in the tile buffer are
encoded in the tile.
"""
exprs: list[Expr] = []
geometry_column = self.get_geometry_column(geom)

Expand Down Expand Up @@ -593,6 +600,19 @@ def cql_where( # noqa: C901
bounds.right,
bounds.top,
)
# Expand the envelope by the tile buffer (`ST_AsMVTGeom` keeps
# geometries up to `tile_buffer` pixels outside of the tile).
# The buffer doesn't extend past the TMS bounds (but tiles
# outside of the TMS bounds are not shrunk).
if tile_buffer:
margin_x = (east - west) * tile_buffer / tile_resolution
margin_y = (north - south) * tile_buffer / tile_resolution
tms_bbox = tms.xy_bbox
west = max(west - margin_x, min(west, tms_bbox.left))
south = max(south - margin_y, min(south, tms_bbox.bottom))
east = min(east + margin_x, max(east, tms_bbox.right))
north = min(north + margin_y, max(north, tms_bbox.top))

# Bounds are now in TMS CRS
tms_srid = tms.crs.to_epsg() or 4326
tile_target_srid = col_srid if col_srid is not None else 4326
Expand Down
2 changes: 2 additions & 0 deletions tipg/duck/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,8 @@ async def get_tile(
dt=dt,
tms=tms,
tile=tile,
tile_buffer=mvt_settings.tile_buffer,
tile_resolution=mvt_settings.tile_resolution,
)

sorts = self._sort_expressions(sortby)
Expand Down
2 changes: 2 additions & 0 deletions tipg/pg/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ async def get_tile(
dt=dt,
tms=tms,
tile=tile,
tile_buffer=mvt_settings.tile_buffer,
tile_resolution=mvt_settings.tile_resolution,
)

sorts = self._sort_expressions(sortby)
Expand Down
Loading