-
Notifications
You must be signed in to change notification settings - Fork 42
/
minimal-mvt.py
176 lines (143 loc) · 5.6 KB
/
minimal-mvt.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
import http.server
import socketserver
import re
import psycopg2
import json
# Database to connect to
DATABASE = {
'user': 'pramsey',
'password': 'password',
'host': 'localhost',
'port': '5432',
'database': 'nyc'
}
# Table to query for MVT data, and columns to
# include in the tiles.
TABLE = {
'table': 'nyc_streets',
'srid': '26918',
'geomColumn': 'geom',
'attrColumns': 'gid, name, type'
}
# HTTP server information
HOST = 'localhost'
PORT = 8080
########################################################################
class TileRequestHandler(http.server.BaseHTTPRequestHandler):
DATABASE_CONNECTION = None
# Search REQUEST_PATH for /{z}/{x}/{y}.{format} patterns
def pathToTile(self, path):
m = re.search(r'^\/(\d+)\/(\d+)\/(\d+)\.(\w+)', path)
if (m):
return {'zoom': int(m.group(1)),
'x': int(m.group(2)),
'y': int(m.group(3)),
'format': m.group(4)}
else:
return None
# Do we have all keys we need?
# Do the tile x/y coordinates make sense at this zoom level?
def tileIsValid(self, tile):
if not ('x' in tile and 'y' in tile and 'zoom' in tile):
return False
if 'format' not in tile or tile['format'] not in ['pbf', 'mvt']:
return False
size = 2 ** tile['zoom'];
if tile['x'] >= size or tile['y'] >= size:
return False
if tile['x'] < 0 or tile['y'] < 0:
return False
return True
# Calculate envelope in "Spherical Mercator" (https://epsg.io/3857)
def tileToEnvelope(self, tile):
# Width of world in EPSG:3857
worldMercMax = 20037508.3427892
worldMercMin = -1 * worldMercMax
worldMercSize = worldMercMax - worldMercMin
# Width in tiles
worldTileSize = 2 ** tile['zoom']
# Tile width in EPSG:3857
tileMercSize = worldMercSize / worldTileSize
# Calculate geographic bounds from tile coordinates
# XYZ tile coordinates are in "image space" so origin is
# top-left, not bottom right
env = dict()
env['xmin'] = worldMercMin + tileMercSize * tile['x']
env['xmax'] = worldMercMin + tileMercSize * (tile['x'] + 1)
env['ymin'] = worldMercMax - tileMercSize * (tile['y'] + 1)
env['ymax'] = worldMercMax - tileMercSize * (tile['y'])
return env
# Generate SQL to materialize a query envelope in EPSG:3857.
# Densify the edges a little so the envelope can be
# safely converted to other coordinate systems.
def envelopeToBoundsSQL(self, env):
DENSIFY_FACTOR = 4
env['segSize'] = (env['xmax'] - env['xmin'])/DENSIFY_FACTOR
sql_tmpl = 'ST_Segmentize(ST_MakeEnvelope({xmin}, {ymin}, {xmax}, {ymax}, 3857),{segSize})'
return sql_tmpl.format(**env)
# Generate a SQL query to pull a tile worth of MVT data
# from the table of interest.
def envelopeToSQL(self, env):
tbl = TABLE.copy()
tbl['env'] = self.envelopeToBoundsSQL(env)
# Materialize the bounds
# Select the relevant geometry and clip to MVT bounds
# Convert to MVT format
sql_tmpl = """
WITH
bounds AS (
SELECT {env} AS geom,
{env}::box2d AS b2d
),
mvtgeom AS (
SELECT ST_AsMVTGeom(ST_Transform(t.{geomColumn}, 3857), bounds.b2d) AS geom,
{attrColumns}
FROM {table} t, bounds
WHERE ST_Intersects(t.{geomColumn}, ST_Transform(bounds.geom, {srid}))
)
SELECT ST_AsMVT(mvtgeom.*) FROM mvtgeom
"""
return sql_tmpl.format(**tbl)
# Run tile query SQL and return error on failure conditions
def sqlToPbf(self, sql):
# Make and hold connection to database
if not self.DATABASE_CONNECTION:
try:
self.DATABASE_CONNECTION = psycopg2.connect(**DATABASE)
except (Exception, psycopg2.Error) as error:
self.send_error(500, "cannot connect: %s" % (str(DATABASE)))
return None
# Query for MVT
with self.DATABASE_CONNECTION.cursor() as cur:
cur.execute(sql)
if not cur:
self.send_error(404, "sql query failed: %s" % (sql))
return None
return cur.fetchone()[0]
return None
# Handle HTTP GET requests
def do_GET(self):
tile = self.pathToTile(self.path)
if not (tile and self.tileIsValid(tile)):
self.send_error(400, "invalid tile path: %s" % (self.path))
return
env = self.tileToEnvelope(tile)
sql = self.envelopeToSQL(env)
pbf = self.sqlToPbf(sql)
self.log_message("path: %s\ntile: %s\n env: %s" % (self.path, tile, env))
self.log_message("sql: %s" % (sql))
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-type", "application/vnd.mapbox-vector-tile")
self.end_headers()
self.wfile.write(pbf)
########################################################################
with http.server.HTTPServer((HOST, PORT), TileRequestHandler) as server:
try:
print("serving at port", PORT)
server.serve_forever()
except KeyboardInterrupt:
if self.DATABASE_CONNECTION:
self.DATABASE_CONNECTION.close()
print('^C received, shutting down server')
server.socket.close()