Skip to content

Commit

Permalink
Created vector tiles endpoint to serve aggregationlayers.
Browse files Browse the repository at this point in the history
The supported formats are json and pbf.
  • Loading branch information
yellowcap committed Jun 1, 2017
1 parent 6ece94b commit fecfad8
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ install:
- pip install coverage==4.4.1
- pip install coveralls

script: coverage run --include="raster-aggregation/*" $(which django-admin.py) test
script: coverage run --include="raster_aggregation/*" $(which django-admin.py) test

after_success: coveralls

Expand Down
14 changes: 13 additions & 1 deletion raster_aggregation/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.http import HttpResponseRedirect
from django.shortcuts import render

from .models import AggregationArea, AggregationLayer, ValueCountResult
from .models import AggregationArea, AggregationLayer, AggregationLayerGroup, ValueCountResult
from .tasks import aggregation_layer_parser, compute_value_count_for_aggregation_layer


Expand Down Expand Up @@ -88,6 +88,18 @@ def compute_value_count(self, request, queryset):
)


class AggregationLayerInLine(admin.TabularInline):
model = AggregationLayerGroup.aggregationlayers.through


class AggregationLayerGroupAdmin(admin.ModelAdmin):
inlines = (
AggregationLayerInLine,
)
exclude = ['aggregationlayers']


admin.site.register(AggregationArea)
admin.site.register(ValueCountResult, ValueCountResultAdmin)
admin.site.register(AggregationLayer, ComputeActivityAggregatesModelAdmin)
admin.site.register(AggregationLayerGroup, AggregationLayerGroupAdmin)
42 changes: 42 additions & 0 deletions raster_aggregation/migrations/0014_auto_20170601_0550.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.1 on 2017-06-01 05:50
from __future__ import unicode_literals

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('raster_aggregation', '0013_auto_20170531_1015'),
]

operations = [
migrations.CreateModel(
name='AggregationLayerGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=250)),
],
),
migrations.CreateModel(
name='AggregationLayerZoomRange',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('min_zoom', models.IntegerField()),
('max_zoom', models.IntegerField()),
('aggregationlayer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='raster_aggregation.AggregationLayer')),
('aggregationlayergroup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='raster_aggregation.AggregationLayerGroup')),
],
),
migrations.AddField(
model_name='aggregationlayergroup',
name='aggregationlayers',
field=models.ManyToManyField(through='raster_aggregation.AggregationLayerZoomRange', to='raster_aggregation.AggregationLayer'),
),
migrations.AlterUniqueTogether(
name='aggregationlayerzoomrange',
unique_together=set([('aggregationlayergroup', 'aggregationlayer')]),
),
]
31 changes: 31 additions & 0 deletions raster_aggregation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,37 @@ def log(self, msg, reset=False):
self.save()


class AggregationLayerGroup(models.Model):
"""
A set of aggregation layers to be used through the vector tile endpoint.
The zoom level of each layer.
"""
name = models.CharField(max_length=250)
aggregationlayers = models.ManyToManyField(AggregationLayer, through='AggregationLayerZoomRange')

def __str__(self):
return self.name


class AggregationLayerZoomRange(models.Model):
"""
Zoom range through which an aggregation layer should be available for display.
"""
aggregationlayergroup = models.ForeignKey(AggregationLayerGroup)
aggregationlayer = models.ForeignKey(AggregationLayer)
min_zoom = models.IntegerField()
max_zoom = models.IntegerField()

class Meta:
unique_together = ('aggregationlayergroup', 'aggregationlayer')

def __str__(self):
return 'Group {0} - Layer {1}'.format(
self.aggregationlayergroup.name,
self.aggregationlayer.name,
)


class AggregationArea(models.Model):
"""
Aggregation area polygons.
Expand Down
9 changes: 8 additions & 1 deletion raster_aggregation/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from rest_framework import routers

from django.conf.urls import include, url
from raster_aggregation.views import AggregationAreaViewSet, ValueCountResultViewSet
from raster_aggregation.views import AggregationAreaViewSet, ValueCountResultViewSet, VectorTilesView

router = routers.DefaultRouter()

Expand All @@ -14,4 +14,11 @@

url(r'api/', include(router.urls)),

# Vector tiles endpoint.
url(
r'^vtiles/(?P<layergroup>[^/]+)/(?P<z>[0-9]+)/(?P<x>[0-9]+)/(?P<y>[0-9]+)(?P<response_format>\.json|\.pbf)$',
VectorTilesView.as_view(),
name='vector_tiles'
),

]
48 changes: 47 additions & 1 deletion raster_aggregation/views.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
from __future__ import unicode_literals

import mapbox_vector_tile
from raster.models import RasterLayer
from raster.tiles.const import WEB_MERCATOR_SRID
from raster.tiles.utils import tile_bounds
from rest_framework import filters, viewsets
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
from rest_framework_gis.filters import InBBOXFilter

from django.contrib.gis.db.models.functions import Intersection, Transform
from django.contrib.gis.gdal import OGRGeometry
from django.db import IntegrityError
from django.http import Http404, HttpResponse
from django.views.generic import View
from raster_aggregation.exceptions import DuplicateError
from raster_aggregation.models import AggregationArea, AggregationLayer, ValueCountResult
from raster_aggregation.models import AggregationArea, AggregationLayer, AggregationLayerGroup, ValueCountResult
from raster_aggregation.serializers import (
AggregationAreaGeoSerializer, AggregationAreaSimplifiedSerializer, AggregationLayerSerializer,
ValueCountResultSerializer
Expand Down Expand Up @@ -96,3 +103,42 @@ def get_queryset(self):
if zoom:
queryset = queryset.filter(aggregationlayer__min_zoom_level__lte=zoom, aggregationlayer__max_zoom_level__gte=zoom)
return queryset


class VectorTilesView(View):

def get(self, request, layergroup, x, y, z, response_format, *args, **kwargs):
# Select which agglayer to use for this tile.
grp = AggregationLayerGroup.objects.get(id=layergroup)
layerzoomrange = grp.aggregationlayerzoomrange_set.filter(
min_zoom__lte=z,
max_zoom__gte=z,
).first()
if not layerzoomrange:
raise Http404('No layer found for this zoom level')
lyr = layerzoomrange.aggregationlayer

# Compute intersection between the tile boundary and the layer geometries.
bounds = tile_bounds(int(x), int(y), int(z))
bounds = OGRGeometry.from_bbox(bounds)
bounds.srid = WEB_MERCATOR_SRID
bounds = bounds.geos
result = AggregationArea.objects.filter(
aggregationlayer=lyr,
geom__intersects=bounds,
).annotate(
intersection=Transform(Intersection('geom', bounds), WEB_MERCATOR_SRID)
).only('id', 'name')

# Render intersection as vector tile in two different available formats.
if response_format == '.json':
result = ['{{"geometry": {0}, "properties": {{"id": {1}, "name": "{2}"}}}}'.format(dat.intersection.geojson, dat.id, dat.name) for dat in result]
result = ','.join(result)
result = '{"type": "FeatureCollection","features":[' + result + ']}'
return HttpResponse(result, content_type="application/json")
elif response_format == '.pbf':
result = [{"geometry": bytes(dat.intersection.wkb), "properties": {"id": dat.id, "name": dat.name}} for dat in result]
result = mapbox_vector_tile.encode({"name": "testlayer", "features": result})
return HttpResponse(result, content_type="application/octet-stream")
else:
raise Http404('Unknown response format {0}'.format(response_format))
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
'djangorestframework>=3.5.4',
'djangorestframework-gis>=0.11',
'drf-extensions>=0.3.1',
'django-filter==1.0.4',
'mapbox-vector-tile>=1.2.0',
],
keywords=['django', 'raster', 'gis', 'gdal', 'celery', 'geo', 'spatial'],
classifiers=[
Expand Down
76 changes: 76 additions & 0 deletions tests/test_vector_tiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import json
import sys
from unittest import skipIf

import mapbox_vector_tile
from raster.tiles.const import WEB_MERCATOR_SRID
from raster.tiles.utils import tile_bounds

from django.contrib.gis.gdal import OGRGeometry
from django.core.urlresolvers import reverse
from django.test import Client
from raster_aggregation.models import AggregationLayerGroup, AggregationLayerZoomRange

from .aggregation_testcase import RasterAggregationTestCase


@skipIf(sys.version_info[:2] == (3, 5), 'The geos version on the CI build breaks this test for Py3.5')
class VectorTilesTests(RasterAggregationTestCase):

def setUp(self):
# Run parent setup
super(VectorTilesTests, self).setUp()

self.group = AggregationLayerGroup.objects.create(name='Test group')
AggregationLayerZoomRange.objects.create(
aggregationlayergroup=self.group,
aggregationlayer=self.agglayer,
max_zoom=12,
min_zoom=3
)
# Instantiate test client
self.client = Client()

def test_vector_tile_endpoint_json(self):
# Get url for a tile.
self.url = reverse('vector_tiles', kwargs={'layergroup': self.group.id, 'z': 11, 'x': 552, 'y': 859, 'response_format': '.json'})
# Setup request with fromula that will multiply the rasterlayer by itself
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
bounds = tile_bounds(552, 859, 11)
bounds = OGRGeometry.from_bbox(bounds)
bounds.srid = WEB_MERCATOR_SRID
result = json.loads(response.content.decode())
self.assertEqual(
'St Petersburg',
result['features'][0]['properties']['name'],
)
self.assertEqual(
'Coverall',
result['features'][1]['properties']['name'],
)

def test_vector_tile_endpoint_pbf(self):
# Get url for a tile.
self.url = reverse('vector_tiles', kwargs={'layergroup': self.group.id, 'z': 11, 'x': 552, 'y': 859, 'response_format': '.pbf'})
# Setup request with fromula that will multiply the rasterlayer by itself
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
result = mapbox_vector_tile.decode(response.content)
self.assertEqual(
'St Petersburg',
result['testlayer']['features'][0]['properties']['name'],
)
self.assertEqual(
'Coverall',
result['testlayer']['features'][1]['properties']['name'],
)

def test_vector_tile_wrong_format(self):
# Get url for a tile, switch to an invalid format.
self.url = reverse('vector_tiles', kwargs={'layergroup': self.group.id, 'z': 11, 'x': 552, 'y': 859, 'response_format': '.json'})
self.url = self.url.split('.json')[0] + '.doc'
# Setup request with fromula that will multiply the rasterlayer by itself
response = self.client.get(self.url)
self.assertEqual(response.status_code, 404)
self.assertTrue('The requested URL /vtiles/3/11/552/859.doc was not found on this server.' in response.content.decode())

0 comments on commit fecfad8

Please sign in to comment.