Skip to content

Commit de0d9e8

Browse files
committed
built-in vector tile server via ST_AsMVT()
1 parent fcdfcf9 commit de0d9e8

3 files changed

Lines changed: 314 additions & 2 deletions

File tree

tests/test_vector_tile.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import unittest
2+
from .base import APITestCase
3+
from django.conf import settings
4+
5+
6+
class VectorTileTestCase(APITestCase):
7+
@unittest.skipIf(settings.VARIANT == "postgis", "postgis supports tiles")
8+
def test_no_tiles(self):
9+
tile = self.client.get("/tiles/0/0/0.pbf")
10+
self.assertEqual(
11+
tile.content.decode(),
12+
"Tile server not supported with this database engine.",
13+
)
14+
15+
@unittest.skipUnless(settings.VARIANT == "postgis", "requires postgis")
16+
def test_tiles(self):
17+
from .gis_app.models import PointModel
18+
19+
empty_tile = self.client.get("/tiles/0/0/0.pbf").content
20+
self.assertEqual(b"", empty_tile)
21+
22+
PointModel.objects.create(pk=1, geometry="POINT(34 -84)")
23+
single_point = self.client.get("/tiles/0/0/0.pbf").content
24+
self.assertEqual(
25+
single_point,
26+
b'\x1a\x1e\n\npointmodel\x12\x0b\x08\x01\x18\x01"\x05\t\x86&\x84>(\x80 x\x02',
27+
)

wq/db/rest/maps.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
from django.db.models import Func, Value
2+
from django.http import HttpResponse
3+
from django.db import connection
4+
from django.conf import settings
5+
6+
7+
def supports_vector_tiles():
8+
backend = settings.DATABASES.get("default", {}).get("ENGINE")
9+
10+
if backend == "django.contrib.gis.db.backends.postgis":
11+
return True
12+
else:
13+
return False
14+
15+
16+
def tiles(router, request, z, x, y):
17+
if not supports_vector_tiles():
18+
return HttpResponse(
19+
"Tile server not supported with this database engine.",
20+
content_type="text/plain",
21+
status=404,
22+
)
23+
tiledata = []
24+
envelope = TileEnvelope(z, x, y)
25+
cursor = connection.cursor()
26+
for name, conf in router.config["pages"].items():
27+
if not conf.get("list") or not conf.get("geometry_fields"):
28+
continue
29+
30+
fields = ["id"]
31+
if lookup := conf.get("lookup"):
32+
fields.append(lookup)
33+
fields += list(conf.get("vector_tile_fields") or [])
34+
35+
queryset = router.get_queryset_for_model(name, request)
36+
for field in conf["geometry_fields"]:
37+
model_info = (name, queryset, field["name"], fields)
38+
tiledata.append(get_tile_data(model_info, envelope, cursor))
39+
40+
return HttpResponse(
41+
b"".join(tiledata),
42+
content_type="application/vnd.mapbox-vector-tile",
43+
)
44+
45+
46+
def get_tile_data(model_info, envelope, cursor):
47+
name, queryset, geometry_field, fields = model_info
48+
geometry_mvt = f"{geometry_field}_mvt"
49+
fields = fields + [geometry_mvt]
50+
tile = (
51+
queryset.filter(geometry__bboverlaps=envelope)
52+
.annotate(**{geometry_mvt: TileGeom(geometry_field, envelope)})
53+
.values(*fields)
54+
)
55+
sql, params = tile.query.sql_with_params()
56+
sql = sql.replace(f'::bytea AS "{geometry_mvt}"', f' AS "{geometry_mvt}"')
57+
cursor.execute(
58+
f"SELECT ST_AsMVT(tile.*, '{name}', 4096, '{geometry_mvt}', 'id') FROM ({sql}) AS tile",
59+
params,
60+
)
61+
row = cursor.fetchone()
62+
return row[0]
63+
64+
65+
class TileEnvelope(Func):
66+
arity = 3
67+
function = "ST_TileEnvelope"
68+
69+
def __init__(self, z, x, y):
70+
from django.contrib.gis.db.models import GeometryField
71+
72+
self.output_field = GeometryField(srid=3857)
73+
74+
super().__init__(Value(z), Value(x), Value(y))
75+
76+
77+
class TileGeom(Func):
78+
arity = 2
79+
function = "ST_AsMVTGeom"
80+
81+
def __init__(self, geometry, envelope):
82+
from django.contrib.gis.db.models.functions import Transform
83+
84+
geometry = Transform(geometry, srid=3857)
85+
super().__init__(geometry, envelope)
86+
87+
88+
def update_tiles_url(plugin_conf, base_url):
89+
if not plugin_conf:
90+
return
91+
if "tiles" in plugin_conf:
92+
return
93+
if not supports_vector_tiles():
94+
return
95+
plugin_conf["tiles"] = base_url + "/tiles/{z}/{x}/{y}.pbf"
96+
97+
98+
def update_geometry_fields(conf):
99+
if conf.get("form") and "geometry_fields" not in conf:
100+
geometry_fields = get_geometry_fields(conf["form"])
101+
if geometry_fields:
102+
conf["geometry_fields"] = geometry_fields
103+
104+
105+
def update_map_config(conf, pages):
106+
if not conf.get("map"):
107+
return
108+
109+
if conf.get("map") is True:
110+
if conf.get("list"):
111+
conf["map"] = [
112+
{
113+
"mode": "list",
114+
"auto_layers": True,
115+
},
116+
{"mode": "detail", "auto_layers": True},
117+
{"mode": "edit", "auto_layers": True},
118+
]
119+
else:
120+
conf["map"] = {
121+
"mapId": "map",
122+
"auto_layers": True,
123+
}
124+
125+
maps = conf.get("map")
126+
if isinstance(maps, dict):
127+
maps = [maps]
128+
for map_conf in maps:
129+
if map_conf.get("auto_layers") or map_conf.get("autoLayers"):
130+
mode = map_conf.get("mode")
131+
map_conf.pop("auto_layers", None)
132+
map_conf.pop("autoLayers", None)
133+
layers = map_conf.get("layers") or []
134+
if mode in ("list", "detail") and not layers:
135+
layers += get_context_layers(conf, mode)
136+
if supports_vector_tiles():
137+
layers += get_tile_layers(pages, mode)
138+
map_conf["layers"] = layers
139+
140+
return conf
141+
142+
143+
def get_geometry_fields(form, prefix=""):
144+
geometry_fields = []
145+
for field in form or []:
146+
if field["type"].startswith("geo"):
147+
geometry_fields.append(
148+
{
149+
"name": prefix + field["name"],
150+
"label": field["label"] or field["name"],
151+
"type": field["type"],
152+
}
153+
)
154+
elif field["type"] == "group":
155+
geometry_fields += get_geometry_fields(
156+
field["children"],
157+
prefix=field["name"] + "." if field["name"] else "",
158+
)
159+
elif field["type"] == "repeat":
160+
geometry_fields += get_geometry_fields(
161+
field["children"], prefix=field["name"] + "[]."
162+
)
163+
164+
return geometry_fields
165+
166+
167+
def get_page_label(conf, mode):
168+
if mode == "list":
169+
page_label = conf.get("verbose_name_plural") or conf["name"]
170+
else:
171+
page_label = conf.get("verbose_name") or conf.get["name"]
172+
if page_label == page_label.lower():
173+
page_label = page_label.title()
174+
return page_label
175+
176+
177+
def get_geometry_label(conf, field, mode):
178+
page_label = get_page_label(conf, mode)
179+
if len(conf.get("geometry_fields") or []) > 1:
180+
field_label = field["label"] or field["name"]
181+
else:
182+
field_label = None
183+
184+
if field_label:
185+
return f"{page_label} - {field_label}"
186+
else:
187+
return page_label
188+
189+
190+
def get_context_layers(conf, mode):
191+
if mode == "list":
192+
lookup_fn = "context_feature_collection"
193+
else:
194+
lookup_fn = "context_feature"
195+
196+
layers = []
197+
for field in conf.get("geometry_fields") or []:
198+
layer_conf = {
199+
"name": get_geometry_label(conf, field, mode),
200+
"type": "geojson",
201+
"data": [lookup_fn, field["name"]],
202+
"popup": conf["name"],
203+
}
204+
if mode == "list":
205+
layer_conf["cluster"] = True # TODO: implement in @wq/map-gl
206+
if conf.get("map_color"):
207+
layer_conf["color"] = conf["map_color"]
208+
# TODO: layer_conf["legend"] = ...
209+
layers.append(layer_conf)
210+
return layers
211+
212+
213+
def get_tile_layers(pages, mode):
214+
layers = []
215+
for conf in sorted(pages.values(), key=layer_sort_key):
216+
page_name = conf["name"]
217+
fields = conf.get("geometry_fields")
218+
if not fields:
219+
continue
220+
for index, field in enumerate(fields):
221+
field_name = field["name"]
222+
layer_name = (
223+
page_name if index == 0 else f"{page_name}_{field_name}"
224+
)
225+
geometry_label = get_geometry_label(conf, field, "list")
226+
if mode:
227+
geometry_label = f"All {geometry_label}"
228+
layer_conf = {
229+
"name": geometry_label,
230+
"type": "vector-tile",
231+
"layer": layer_name,
232+
"popup": page_name,
233+
}
234+
if conf.get("map_icon"):
235+
layer_conf["icon"] = conf["map_icon"]
236+
if mode != "edit":
237+
layer_conf["identifyLayers"] = [layer_name]
238+
else:
239+
if conf.get("map_color"):
240+
layer_conf["color"] = conf["map_color"]
241+
# TODO: layer_conf["legend"] = ...
242+
if mode != "edit":
243+
if field.get("type") == "geopoint":
244+
layer_conf["identifyLayers"] = [f"{layer_name}-circle"]
245+
elif field.get("type") == "geotrace":
246+
layer_conf["identifyLayers"] = [f"{layer_name}-line"]
247+
elif field.get("type") == "geoshape":
248+
layer_conf["identifyLayers"] = [f"{layer_name}-fill"]
249+
250+
if "map_active" in conf:
251+
layer_conf["active"] = conf["map_active"]
252+
elif mode:
253+
layer_conf["active"] = False
254+
255+
layers.append(layer_conf)
256+
return layers
257+
258+
259+
def layer_sort_key(conf):
260+
order = conf.get("order") or 0
261+
label = get_page_label(conf, "list")
262+
return order, label

wq/db/rest/routers.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django.utils.encoding import force_str
2-
from django.urls import re_path
2+
from django.urls import re_path, path
33

44
from django.conf import settings
55
from rest_framework.routers import DefaultRouter, Route
@@ -11,6 +11,12 @@
1111
from .views import SimpleViewSet, ModelViewSet
1212
from .renderers import JSONRenderer, ESMRenderer
1313
from .exceptions import ImproperlyConfigured
14+
from .maps import (
15+
tiles,
16+
update_tiles_url,
17+
update_geometry_fields,
18+
update_map_config,
19+
)
1420

1521

1622
class ModelRouter(DefaultRouter):
@@ -32,6 +38,7 @@ class ModelRouter(DefaultRouter):
3238
include_root_view = False
3339
include_config_view = True
3440
include_multi_view = True
41+
include_tiles_view = True
3542

3643
default_serializer_class = None
3744

@@ -321,7 +328,7 @@ def base_config(self):
321328
pages = {}
322329
for page in self._extra_pages:
323330
conf, view = self.get_page(page)
324-
pages[page] = conf
331+
pages[page] = conf.copy()
325332
for model in self._models:
326333
if not has_perm(user, model, "view"):
327334
continue
@@ -353,9 +360,16 @@ def base_config(self):
353360
"name": "index",
354361
"show_in_index": False,
355362
"verbose_name": site_title,
363+
"map": True,
356364
},
357365
)
358366

367+
for conf in pages.values():
368+
update_geometry_fields(conf)
369+
370+
for conf in pages.values():
371+
update_map_config(conf, pages)
372+
359373
self._base_config = {
360374
"pages": pages,
361375
"site_title": site_title,
@@ -367,6 +381,9 @@ def base_config(self):
367381
}
368382
if getattr(settings, "WQ_CONFIG", None):
369383
self._base_config.update(settings.WQ_CONFIG)
384+
385+
update_tiles_url(self._base_config.get("map"), self.get_base_url())
386+
370387
if settings.DEBUG:
371388
self._base_config["debug"] = True
372389
self._base_config.update(self._extra_config)
@@ -484,6 +501,9 @@ def get_multi(self, request, urls):
484501
result[listurl] = self.paginate(model, 1, request)
485502
return Response(result)
486503

504+
def tiles(self, request, z, x, y):
505+
return tiles(self, request, z, x, y)
506+
487507
def get_urls(self):
488508
# Register viewsets with DefaultRouter just before returning urls
489509

@@ -526,6 +546,9 @@ def register(config, viewset):
526546
# / - Skip registration and directly generate custom URLs
527547
urls.extend(self.get_root_urls(root["view"], root["name"]))
528548

549+
if self.include_tiles_view:
550+
urls.append(path("tiles/<int:z>/<int:x>/<int:y>.pbf", self.tiles))
551+
529552
return urls
530553

531554
def get_root_urls(self, viewset, name):

0 commit comments

Comments
 (0)