├── .eslint ├── .gitignore ├── CODEOWNERS ├── ISSUE_TEMPLATE.md ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── example ├── README.md ├── bus_routes.geojson ├── cors_server.py ├── cors_server_py3.py ├── lines.geojson ├── npm-debug.log ├── script.js └── stops.geojson ├── package.json └── src ├── helpers.js └── index.js /.eslint: -------------------------------------------------------------------------------- 1 | { 2 | parser: "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": false, 6 | "node": true 7 | }, 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | example/tiles -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @NYCPlanning/Engineering 2 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | #### User Story 3 | As a {type of user}, I want {some goal} so that {some reason} 4 | 5 | #### Description 6 | What's it all about? 7 | 8 | 9 | #### What should happen: 10 | 11 | #### What happened instead: 12 | 13 | #### How to reproduce this bug: 14 | 15 | 1. Step one 16 | 2. ??? 17 | 3. Error 18 | 19 | #### Browser(s) and Device(s) observed on: 20 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 NYC Department of City Planning 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | This PR {summarize the PR} 4 | 5 | Changes Proposed: 6 | - New Feature 7 | - API change 8 | - New Domain-specific language 9 | - Refactor of existing code 10 | 11 | Closes #{issue number(s)} 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # geojson2mvt 2 | 3 | Cuts a file pyramid of static vector tiles (.mvt) from a geojson file 4 | 5 | # Why 6 | 7 | We are using mapboxGL in [The Capital Planning Platform](http://capitalplanning.nyc.gov), and needed an alternative to downloading large static data files for local rendering on the map. We didn't want to put them into a service that would require $ and maintenance, so static vector tiles seemed like a useful alternative for data that will not change very often. 8 | 9 | ## How to Use 10 | 11 | Install 12 | `npm install geojson2mvt` 13 | 14 | `geojson2mvt` takes a config object with the GeoJSONs to encode and other options, and builds the pyramid in the specified output directory 15 | 16 | ``` 17 | var fs = require('fs'); 18 | var geojson2mvt = require('geojson2mvt'); 19 | 20 | var options = { 21 | layers: { 22 | layer0: JSON.parse(fs.readFileSync('bus_routes.geojson', "utf8")), 23 | layer1: JSON.parse(fs.readFileSync('stops.geojson', "utf8")) 24 | }, 25 | rootDir: 'tiles', 26 | bbox : [40.426042,-74.599228,40.884448,-73.409958], //[south,west,north,east] 27 | zoom : { 28 | min : 8, 29 | max : 18, 30 | } 31 | }; 32 | // build the static tile pyramid 33 | geojson2mvt(options); 34 | ``` 35 | Check out `/example` for a test project that you can try locally 36 | 37 | ## Options 38 | 39 | `layers` - Object (required) - GeoJSONs to create a vector tileset from. Keys are the layer names that will be used to access data from the respective GeoJSON when displaying data from the MVT. 40 | 41 | `rootDir` - string (required) - the filepath of the directory that will be the root of the file pyramid. It will be created if it doesn't exist. 42 | 43 | `bbox` - array (required) - array of lat/lon bounds like `[s,w,n,e]` 44 | 45 | `zoom` - object (required) - object with `min` and `max` properties for the desired zoom levels in the tile pyramid 46 | 47 | ## Backwards compatibility 48 | 49 | Instead of providing a single config object, you can provide two arguments: a geoJson and config object without a `layers` property, but instead with a `layerName` property for the name for the imported geoJson in the MVT. 50 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # geojson2mvt example 2 | 3 | This example will cut vector tiles from a geojson file of NYC's bus routes. 4 | 5 | ## How to Use 6 | 7 | Be sure to install dependencies on the `geojson2mvt` root directory 8 | 9 | `npm install` 10 | 11 | Run the script 12 | 13 | `node script.js` 14 | 15 | ## How it works 16 | 17 | ``` 18 | // require geojson2mvt 19 | var geojson2mvt = require('./src'); 20 | 21 | // specify the path to the geojson file 22 | const filePath = './bus_routes.geojson'; 23 | 24 | // create an options object 25 | var options = { 26 | rootDir: 'tiles', 27 | bbox : [40.426042,-74.599228,40.884448,-73.409958], //[south,west,north,east] 28 | zoom : { 29 | min : 8, 30 | max : 18, 31 | }, 32 | layerName: 'layer0', 33 | }; 34 | 35 | // call geojson2mvt 36 | geojson2mvt(filePath, options); 37 | ``` 38 | 39 | ## Try them out with Maputnik 40 | 41 | Also in this example directory is a modified python SimpleHTTPServer that will not have any CORS issues. Run it like `python cors_server.py` and it will create a webserver in this directory. The tile pyramid should now be available at `http://localhost:31338/tiles/{z}/{x}/{y}.mvt` 42 | 43 | Create a new xyz Vector Tile source in Maputnik with this template, and you should be able to add a style with your freshly cut vector tiles. 44 | 45 | Happy Mapping! 46 | -------------------------------------------------------------------------------- /example/cors_server.py: -------------------------------------------------------------------------------- 1 | 2 | import SimpleHTTPServer 3 | class CORSHTTPRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): 4 | def send_head(self): 5 | """Common code for GET and HEAD commands. 6 | This sends the response code and MIME headers. 7 | Return value is either a file object (which has to be copied 8 | to the outputfile by the caller unless the command was HEAD, 9 | and must be closed by the caller under all circumstances), or 10 | None, in which case the caller has nothing further to do. 11 | """ 12 | path = self.translate_path(self.path) 13 | f = None 14 | if os.path.isdir(path): 15 | if not self.path.endswith('/'): 16 | # redirect browser - doing basically what apache does 17 | self.send_response(301) 18 | self.send_header("Location", self.path + "/") 19 | self.end_headers() 20 | return None 21 | for index in "index.html", "index.htm": 22 | index = os.path.join(path, index) 23 | if os.path.exists(index): 24 | path = index 25 | break 26 | else: 27 | return self.list_directory(path) 28 | ctype = self.guess_type(path) 29 | try: 30 | # Always read in binary mode. Opening files in text mode may cause 31 | # newline translations, making the actual size of the content 32 | # transmitted *less* than the content-length! 33 | f = open(path, 'rb') 34 | except IOError: 35 | self.send_error(404, "File not found") 36 | return None 37 | self.send_response(200) 38 | self.send_header("Content-type", ctype) 39 | fs = os.fstat(f.fileno()) 40 | self.send_header("Content-Length", str(fs[6])) 41 | self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) 42 | self.send_header("Access-Control-Allow-Origin", "*") 43 | self.end_headers() 44 | return f 45 | 46 | 47 | if __name__ == "__main__": 48 | import os 49 | import SocketServer 50 | 51 | PORT = 31338 52 | 53 | Handler = CORSHTTPRequestHandler 54 | #Handler = SimpleHTTPServer.SimpleHTTPRequestHandler 55 | 56 | httpd = SocketServer.TCPServer(("", PORT), Handler) 57 | 58 | print "serving at port", PORT 59 | httpd.serve_forever() -------------------------------------------------------------------------------- /example/cors_server_py3.py: -------------------------------------------------------------------------------- 1 | 2 | import http.server 3 | class CORSHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): 4 | def send_head(self): 5 | """Common code for GET and HEAD commands. 6 | This sends the response code and MIME headers. 7 | Return value is either a file object (which has to be copied 8 | to the outputfile by the caller unless the command was HEAD, 9 | and must be closed by the caller under all circumstances), or 10 | None, in which case the caller has nothing further to do. 11 | """ 12 | path = self.translate_path(self.path) 13 | f = None 14 | if os.path.isdir(path): 15 | if not self.path.endswith('/'): 16 | # redirect browser - doing basically what apache does 17 | self.send_response(301) 18 | self.send_header("Location", self.path + "/") 19 | self.end_headers() 20 | return None 21 | for index in "index.html", "index.htm": 22 | index = os.path.join(path, index) 23 | if os.path.exists(index): 24 | path = index 25 | break 26 | else: 27 | return self.list_directory(path) 28 | ctype = self.guess_type(path) 29 | try: 30 | # Always read in binary mode. Opening files in text mode may cause 31 | # newline translations, making the actual size of the content 32 | # transmitted *less* than the content-length! 33 | f = open(path, 'rb') 34 | except IOError: 35 | self.send_error(404, "File not found") 36 | return None 37 | self.send_response(200) 38 | self.send_header("Content-type", ctype) 39 | fs = os.fstat(f.fileno()) 40 | self.send_header("Content-Length", str(fs[6])) 41 | self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) 42 | self.send_header("Access-Control-Allow-Origin", "*") 43 | self.end_headers() 44 | return f 45 | 46 | 47 | if __name__ == "__main__": 48 | import os 49 | import socketserver 50 | 51 | PORT = 31338 52 | 53 | Handler = CORSHTTPRequestHandler 54 | #Handler = http.server.SimpleHTTPRequestHandler 55 | 56 | httpd = socketserver.TCPServer(("", PORT), Handler) 57 | 58 | print("serving at port", PORT) 59 | httpd.serve_forever() 60 | -------------------------------------------------------------------------------- /example/npm-debug.log: -------------------------------------------------------------------------------- 1 | 0 info it worked if it ends with ok 2 | 1 verbose cli [ '/usr/local/Cellar/node/7.5.0/bin/node', 3 | 1 verbose cli '/usr/local/bin/npm', 4 | 1 verbose cli 'version', 5 | 1 verbose cli 'patch', 6 | 1 verbose cli '-m', 7 | 1 verbose cli 'make main method take geojson object instead of filepath' ] 8 | 2 info using npm@4.1.2 9 | 3 info using node@v7.5.0 10 | 4 info git [ 'status', '--porcelain' ] 11 | 5 verbose stack Error: Git working directory not clean. 12 | 5 verbose stack M README.md 13 | 5 verbose stack M package.json 14 | 5 verbose stack M src/index.js 15 | 5 verbose stack at /usr/local/lib/node_modules/npm/lib/version.js:247:19 16 | 5 verbose stack at /usr/local/lib/node_modules/npm/lib/utils/no-progress-while-running.js:21:8 17 | 5 verbose stack at ChildProcess.exithandler (child_process.js:202:7) 18 | 5 verbose stack at emitTwo (events.js:106:13) 19 | 5 verbose stack at ChildProcess.emit (events.js:192:7) 20 | 5 verbose stack at maybeClose (internal/child_process.js:890:16) 21 | 5 verbose stack at Socket. (internal/child_process.js:334:11) 22 | 5 verbose stack at emitOne (events.js:96:13) 23 | 5 verbose stack at Socket.emit (events.js:189:7) 24 | 5 verbose stack at Pipe._handle.close [as _onclose] (net.js:501:12) 25 | 6 verbose cwd /Users/chriswhong/Sites/geojson2mvt/example 26 | 7 error Darwin 15.6.0 27 | 8 error argv "/usr/local/Cellar/node/7.5.0/bin/node" "/usr/local/bin/npm" "version" "patch" "-m" "make main method take geojson object instead of filepath" 28 | 9 error node v7.5.0 29 | 10 error npm v4.1.2 30 | 11 error Git working directory not clean. 31 | 11 error M README.md 32 | 11 error M package.json 33 | 11 error M src/index.js 34 | 12 error If you need help, you may report this error at: 35 | 12 error 36 | 13 verbose exit [ 1, true ] 37 | -------------------------------------------------------------------------------- /example/script.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var geojson2mvt = require('../src'); 3 | 4 | var options = { 5 | layers: { 6 | layer0: JSON.parse(fs.readFileSync('bus_routes.geojson', "utf8")), 7 | layer1: JSON.parse(fs.readFileSync('stops.geojson', "utf8")) 8 | }, 9 | rootDir: 'tiles', 10 | bbox : [40.426042,-74.599228,40.884448,-73.409958], //[south,west,north,east] 11 | zoom : { 12 | min : 8, 13 | max : 18, 14 | } 15 | }; 16 | // build the static tile pyramid 17 | geojson2mvt(options); 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geojson2mvt", 3 | "version": "0.0.3", 4 | "description": "", 5 | "keywords": [ 6 | "mapboxgl", 7 | "vector tiles", 8 | "web mapping", 9 | "web maps", 10 | "geojson" 11 | ], 12 | "main": "src/index.js", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/NYCPlanning/geojson2mvt.git" 16 | }, 17 | "scripts": { 18 | "test": "echo \"Error: no test specified\" && exit 1" 19 | }, 20 | "author": "", 21 | "dependencies": { 22 | "geojson-vt": "^2.4.0", 23 | "vt-pbf": "^2.1.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | var helpers = { 2 | //given a bounding box and zoom level, calculate x and y tile ranges 3 | getTileBounds(bbox, zoom) { 4 | var tileBounds = { 5 | xMin: this.long2tile(bbox[1], zoom), 6 | xMax: this.long2tile(bbox[3], zoom), 7 | yMin: this.lat2tile(bbox[2], zoom), 8 | yMax: this.lat2tile(bbox[0], zoom), 9 | }; 10 | return tileBounds; 11 | }, 12 | 13 | //lookup tile name based on lat/lon, courtesy of http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers 14 | long2tile(lon,zoom) { 15 | return (Math.floor((lon+180)/360*Math.pow(2,zoom))); 16 | }, 17 | 18 | lat2tile(lat,zoom) { 19 | return (Math.floor((1-Math.log(Math.tan(lat*Math.PI/180) + 1/Math.cos(lat*Math.PI/180))/Math.PI)/2 *Math.pow(2,zoom))); 20 | }, 21 | }; 22 | 23 | 24 | module.exports = helpers; 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var vtpbf = require('vt-pbf'); 3 | var geojsonvt = require('geojson-vt'); 4 | 5 | var helpers = require('./helpers.js'); 6 | 7 | var geojson2mvt = function(options) { 8 | 9 | if (arguments.length == 2) { 10 | var geoJson = options; 11 | options = arguments[1]; 12 | options.layers = {}; 13 | options.layers[options.layerName] = geoJson; 14 | } 15 | 16 | var layerNames = Object.keys(options.layers); 17 | 18 | var i = 0, ii = layerNames.length; 19 | var tileIndex = new Array(ii); 20 | for (; i < ii; ++i) { 21 | tileIndex[i] = geojsonvt(options.layers[layerNames[i]], { 22 | maxZoom: options.zoom.max, 23 | indexMaxZoom: options.zoom.max, 24 | indexMaxPoints: 0 25 | }); 26 | } 27 | 28 | // create the "root directory" to place downloaded tiles in 29 | try {fs.mkdirSync(options.rootDir, 0777);} 30 | catch(err){ 31 | if (err.code !== 'EEXIST') callback(err); 32 | } 33 | 34 | var tileCount = 0, 35 | tileCoords = {}, 36 | tileBounds; 37 | 38 | for(var z=options.zoom.min; z<=options.zoom.max; z++) { 39 | 40 | //create z directory in the root directory 41 | var zPath = `${options.rootDir}/${z.toString()}/`; 42 | try{ fs.mkdirSync(zPath, 0777) } 43 | catch (err){ 44 | if (err.code !== 'EEXIST') callback(err); 45 | } 46 | 47 | // get the x and y bounds for the current zoom level 48 | var tileBounds = helpers.getTileBounds(options.bbox, z); 49 | console.log(tileBounds) 50 | 51 | // x loop 52 | for(var x=tileBounds.xMin; x<=tileBounds.xMax; x++) { 53 | 54 | // create x directory in the z directory 55 | var xPath = zPath + x.toString(); 56 | try{ fs.mkdirSync(xPath, 0777) } 57 | catch (err){ 58 | if (err.code !== 'EEXIST') callback(err); 59 | } 60 | 61 | 62 | // y loop 63 | for(var y=tileBounds.yMin; y<=tileBounds.yMax; y++) { 64 | 65 | console.log(`Getting tile ${z} ${x} ${y} `) 66 | var mvt = getTile(z, x, y, tileIndex, layerNames); 67 | 68 | // TODO what should be written to the tile if there is no data? 69 | var output = mvt !== null ? mvt : ''; 70 | fs.writeFileSync(`${xPath}/${y}.mvt`, output); 71 | tileCount++; 72 | 73 | } 74 | } 75 | } 76 | 77 | console.log('Done! I made ' + tileCount + ' tiles!'); 78 | }; 79 | 80 | 81 | 82 | 83 | function getTile(z, x, y, tileIndex, layerNames) { 84 | var pbfOptions = {}; 85 | for (var i = 0, ii = tileIndex.length; i < ii; ++i) { 86 | var tile = tileIndex[i].getTile(z, x, y); 87 | 88 | if (tile != null) { 89 | pbfOptions[layerNames[i]] = tile; 90 | } 91 | 92 | } 93 | return Object.keys(pbfOptions).length ? 94 | vtpbf.fromGeojsonVt(pbfOptions) : 95 | null; 96 | }; 97 | 98 | 99 | module.exports = geojson2mvt; 100 | --------------------------------------------------------------------------------