Skip to content

feat(geo): vector tiles of features for maps - #470

Merged
nossila merged 2 commits into
feature/baseapp_geofrom
feature/baseapp_geo-vector-tiles
Oct 8, 2026
Merged

nossila merged 2 commits into
feature/baseapp_geofrom
feature/baseapp_geo-vector-tiles

Conversation

@nossila

@nossila nossila commented Oct 8, 2026

Copy link
Copy Markdown
Member

Summary

Vector tiles for baseapp.geo, to fold into #445. Maps can now draw every feature at every zoom; the geoFeatures connection returns at most 100 per page.

  • Endpoint: GET /v1/geo/tiles/{z}/{x}/{y}.mvt serves GeoJSONFeatures as Mapbox Vector Tiles built by PostGIS (ST_TileEnvelope, ST_AsMVTGeom, ST_AsMVT). No new runtime dependency.
  • First REST surface of the package: baseapp/geo/rest_framework/ (views.py, urls.py, permissions.py), wired through GeoPlugin.v1_urlpatterns in the staticmethod form.

Behavior

  • Zoom ≥ cluster_max_zoom (13): a features layer with every feature in the tile. Points stay points and polygons stay polygons, clipped to the tile plus a 64px buffer. Attributes are id and feature_type.
  • Below it: a clusters layer. Each tile is split into a cluster_grid_size² (16×16) grid; each non-empty cell becomes a point at the centroid of its features, with point_count.
    • Polygons count by ST_PointOnSurface.
    • Tile bounds are half-open, so each feature is counted in exactly one tile and totals add up.
  • Filters: the same query parameters as geoFeatures, by reusing GeoJSONFeatureFilter as-is (feature_type, target_object_id, bbox, near). No new filter API.
  • Errors: the filters raise Django ValidationErrors while the queryset is evaluated, after is_valid(). The view catches them and returns 400 with field errors instead of a 500. test_filterset_raises_validation_error_outside_graphene is untouched.
  • Access: CanViewGeoJSONFeatures calls request.user.has_perm("<app>.view_geojsonfeature"), so a project that overrides GeoPermissionsBackend gets the same rule on tiles as on GraphQL.
  • Responses: 204 for empty tiles and 404 for out-of-range tiles. Cache-Control: public, max-age=300, so a CDN can absorb map pans.

SQL

  • The geography column is cast to geometry before ST_Transform(…, 3857).
  • From zoom 4, a geography __intersects prefilter runs on the tile envelope padded by 10%. It uses the GiST index, which I verified exists: sqlmigrate geo 0001 creates geo_geojsonfeature_geometry_…_id USING GIST ("geometry"). Then an exact planar test in EPSG:3857.
  • Tiles below zoom 4 skip the prefilter: on tiles that large, the geography envelope's great-circle edges diverge from the Mercator edges.
  • Property expressions are aliased internally (_mvt_property_N) and renamed in the outer query, since Django rejects annotations named like model fields (e.g. id).

Extension points

  • Hooks: get_queryset, filter_queryset, get_feature_properties (ORM expressions become MVT attributes).
  • Settings: cluster_max_zoom, cluster_grid_size, layer names and cache_max_age.
  • baseapp.geo.tiles.build_features_tile / build_clusters_tile build tiles from any queryset, for custom endpoints.
  • The README has a section with a MapLibre example and a subclass example.

Tests

  • baseapp/geo/tests/test_rest_tiles.py, 15 tests on real PostGIS, no mocks. They cover tile math, points and polygons, the properties hook, cluster aggregation, the polygon cluster, counts across a tile edge, 204/404, cache headers, filter pass-through, 400 for malformed filters, and permission via has_perm.
  • Tiles are decoded by baseapp/geo/tests/mvt.py, a small protobuf-free MVT decoder. mapbox-vector-tile was the first choice, but it requires protobuf<7. Locking it downgraded protobuf from 7.36 to 6.33 for every consumer of the library, which is too much for a test helper.

Test plan

  • pytest baseapp/geo/tests/: 105 passed. baseapp/geo coverage is 99% (tiles.py 100%, rest_framework/views.py 95%).
  • black, isort, flake8 and ast-grep are clean on the changed files.
  • Consumer check: the whatplant map switches to these tiles in a follow-up there (thousands of occurrences per city).

🤖 Generated with Claude Code

https://claude.ai/code/session_019P1oyApi4BLS8iLfMEGm8b

GET /v1/geo/tiles/{z}/{x}/{y}.mvt serves GeoJSONFeatures as Mapbox Vector Tiles built by
PostGIS (ST_AsMVT), so maps can draw every feature instead of the 100 per page of the
geoFeatures connection. The package's first REST surface, wired through
GeoPlugin.v1_urlpatterns.

- From zoom 13 up, a `features` layer with every feature (points and polygons) and `id` /
  `feature_type` attributes. Below it, a `clusters` layer: a 16x16 grid per tile, one point
  per non-empty cell with a `point_count`; half-open tile bounds count each feature once.
- Same query parameters as geoFeatures (GeoJSONFeatureFilter). The filters' django
  ValidationErrors, raised while the queryset is evaluated, return 400, not 500.
- Access through has_perm("view_geojsonfeature") (rest_framework/permissions.py), so an
  overridden backend applies to tiles too. 204 for empty tiles, Cache-Control max-age=300.
- Geography prefilter on the padded tile envelope (GiST index) from zoom 4, then an exact
  test in EPSG:3857; huge low-zoom tiles skip the prefilter (great-circle edges).
- Extension points: get_queryset, filter_queryset, get_feature_properties (ORM expressions
  as MVT attributes), cluster_max_zoom, cluster_grid_size. baseapp.geo.tiles exposes the
  builders for custom endpoints.
- Tests decode tiles with a small protobuf-free MVT decoder: mapbox-vector-tile would
  downgrade protobuf (it requires < 7) for every consumer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019P1oyApi4BLS8iLfMEGm8b
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 6f4ce42b-8930-41e9-9096-47e5d2e6d7da

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

nossila added a commit to queplanta/baseapp-backend that referenced this pull request Oct 8, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019P1oyApi4BLS8iLfMEGm8b
Review of #470:
- `id` is the feature's DocumentId public_id (as text): the ID the GraphQL API and the
  target_object_id filter use, and sequential pks would let anyone count features. The
  `document` join is one row per feature (DocumentId is unique per content type and object);
  a test pins both.
- Drop the unused logger from the tile view.
- README: document that zooms 0-3 skip the spatial index and scan the filtered table.
- Test decoder: decode uint64 values as unsigned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019P1oyApi4BLS8iLfMEGm8b
nossila added a commit to queplanta/baseapp-backend that referenced this pull request Oct 8, 2026
@nossila
nossila merged commit 2a4b4ae into feature/baseapp_geo Oct 8, 2026
5 checks passed
@nossila
nossila deleted the feature/baseapp_geo-vector-tiles branch October 8, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant