├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── datasources ├── GeoJson.js └── Shp.js ├── index.js ├── lib ├── Map.js ├── cartoRenderer.js ├── projector.js ├── renderer.js ├── routes.js └── utfgrid.js ├── package.json ├── screenshot.png ├── test ├── Map.spec.js ├── cartoRenderer.spec.js ├── cartoRenderer │ ├── markers.json │ ├── markers.mss │ └── markers.png ├── mocha.opts └── projector.spec.js └── test_perf └── projector.js /.gitignore: -------------------------------------------------------------------------------- 1 | # osx noise 2 | .DS_Store 3 | profile 4 | 5 | node_modules 6 | 7 | # pull these data files down separately (they are HUGE and shouldn't be stored in the repo) 8 | geodata 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "0.10" 5 | - "0.8" 6 | addons: 7 | apt: 8 | packages: 9 | - libcairo2-dev 10 | - libjpeg8-dev 11 | - libpango1.0-dev 12 | - libgif-dev 13 | - build-essential 14 | - g++ 15 | after_script: NODE_ENV=test istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2015, Code for America 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | * Neither the name of Code for America nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Nodetiles-Core 2 | ============= 3 | 4 | [![Build Status](https://travis-ci.org/nodetiles/nodetiles-core.png?branch=master)](https://travis-ci.org/nodetiles/nodetiles-core) 5 | [![Dependency Status](https://gemnasium.com/nodetiles/nodetiles-core.png)](https://gemnasium.com/nodetiles/nodetiles-core) 6 | [![Coverage Status](https://coveralls.io/repos/nodetiles/nodetiles-core/badge.png?branch=master)](https://coveralls.io/r/nodetiles/nodetiles-core?branch=master) 7 | [![Code Climate](https://codeclimate.com/github/nodetiles/nodetiles-core.png)](https://codeclimate.com/github/nodetiles/nodetiles-core) 8 | [![NPM version](https://badge.fury.io/js/nodetiles-core.png)](http://badge.fury.io/js/nodetiles-core) 9 | 10 | 11 | Nodetiles-core is a javascript library for rendering map tiles suitable for slippy-maps and static images. Features include: 12 | 13 | - **Flexible Data-connectors**: We offer GeoJson and Shapefile connectors out-of-the-box, but it's easy to build your own. 14 | - **Map Projections**: Transform data between more [3,900+](https://github.com/yuletide/node-proj4js-defs/blob/master/epsg.js) EPSG projections using Proj4.js 15 | - **CartoCSS Support**: We support many (if not most) stylesheet features of [CartoCSS](http://mapbox.com/tilemill/docs/manual/carto/) making it trivial to import your map styles from tools like Tilemill 16 | - **Slippy-map URL helpers**: Easily serve map tiles, UTFGrids, and Tile.json. Check out [nodetiles-init](https://github.com/nodetiles/nodetiles-init) for a simple scaffold. 17 | - **Static maps**: If slippy-maps aren't your style, generate static images of any dimension; checkout [nodetiles-example-static](https://github.com/nodetiles/nodetiles-example-static) for examples. 18 | - **Joyfully simple, pluggable, flexible, powerful**: We built Nodetiles to be easily understandable, extensible and a joy to use. It's built with Javascript and tries to provide a solid foundation of tools that are still easy to understand, extend or replace depending on your needs. [File an issue](https://github.com/nodetiles/nodetiles-core/issues/new) if Nodetiles can't do what you need. 19 | 20 | Screenshot 21 | ------- 22 | 23 | ![Nodetiles Screenshot](https://raw.github.com/nodetiles/nodetiles-core/master/screenshot.png) 24 | 25 | 26 | Example 27 | ------- 28 | ``` 29 | /* Set up the libraries */ 30 | var nodetiles = require('nodetiles-core'), 31 | GeoJsonSource = nodetiles.datasources.GeoJson, 32 | Projector = nodetiles.projector, 33 | fs = require('fs'); // we'll output to a file 34 | 35 | /* Create your map context */ 36 | var map = new nodetiles.Map({ 37 | projection: "EPSG:4326" // set the projection of the map 38 | }); 39 | 40 | /* Add some data */ 41 | map.addData(new GeoJsonSource({ 42 | name: "world", 43 | path: __dirname + '/countries.geojson', 44 | projection: "EPSG:900913" 45 | })); 46 | 47 | /* Link your Carto stylesheet */ 48 | map.addStyle(fs.readFileSync('./style.mss','utf8')); 49 | 50 | /* Render out the map to a file */ 51 | map.render({ 52 | // Make sure your bounds are in the same projection as the map 53 | bounds: {minX: -180, minY: -90, maxX: 180, maxY: 90}, 54 | width: 800, // number of pixels to output 55 | height: 400, 56 | callback: function(err, canvas) { 57 | var file = fs.createWriteStream(__dirname + '/map.png'), 58 | stream = canvas.createPNGStream(); 59 | 60 | stream.on('data', function(chunk){ 61 | file.write(chunk); 62 | }); 63 | 64 | stream.on('end', function(){ 65 | console.log('Saved map.png!'); 66 | }); 67 | } 68 | }); 69 | 70 | 71 | ``` 72 | 73 | Thanks 74 | ------- 75 | 76 | Big THANKS to [Tom Carden](https://github.com/RandomEtc) whose [original gist](https://gist.github.com/668577) inspired this project. He also has other very [useful](https://github.com/RandomEtc/nodemap) [projects](https://github.com/RandomEtc/shapefile-js). 77 | 78 | Projections 79 | ----------- 80 | [Supported projections](https://github.com/yuletide/node-proj4js-defs) 81 | 82 | Copyright 83 | --------- 84 | Copyright (c) 2012-2015 Code for America. See LICENSE for details. 85 | 86 | -------------------------------------------------------------------------------- /datasources/GeoJson.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var projector = require(__dirname + "/../lib/projector"); 3 | 4 | var FILTER_BY_EXTENTS = true; 5 | 6 | var GeoJsonSource = function(options) { 7 | this._projection = projector.util.cleanProjString(options.projection || "EPSG:4326"); 8 | this._path = options.path; // required 9 | this._encoding = options.encoding || "utf8"; 10 | this.name = options.name || options.path.slice(options.path.lastIndexOf("/") + 1); 11 | if (this.name.indexOf(".") !== -1) { 12 | this.name = this.name.slice(0, this.name.indexOf(".")); 13 | } 14 | this.sourceName = this.name; 15 | 16 | // loading synchronization 17 | this._loadCallbacks = []; 18 | this._loading = false; 19 | 20 | // stored state 21 | this._loadError = null; 22 | this._data = null; 23 | this._projectedData = {}; 24 | 25 | }; 26 | 27 | GeoJsonSource.prototype = { 28 | constructor: GeoJsonSource, 29 | 30 | getShapes: function(minX, minY, maxX, maxY, mapProjection, callback) { 31 | if (this._projectedData[mapProjection]){ 32 | var data = this._filterByExtent(this._projectedData[mapProjection], minX, minY, maxX, maxY); 33 | callback(null, data); 34 | } 35 | else { 36 | this.load(function(error, data) { 37 | if (error){ 38 | this._loadError = error; 39 | } 40 | else if (!this._projectedData[mapProjection]) { 41 | this._project(mapProjection); 42 | } 43 | 44 | var data = this._filterByExtent(this._projectedData[mapProjection], minX, minY, maxX, maxY); 45 | callback(this._loadError, data); 46 | }.bind(this)); 47 | } 48 | }, 49 | 50 | load: function(callback) { 51 | if (this._data || this._loadError) { 52 | callback(this._loadError, this._data); 53 | return; 54 | } 55 | 56 | callback && this._loadCallbacks.push(callback); 57 | 58 | if (!this._loading) { 59 | this._loading = true; 60 | 61 | var start = Date.now(); 62 | console.log("Loading data in " + this._path + "..."); 63 | 64 | fs.readFile(this._path, this._encoding, function(error, content) { 65 | if (error) { 66 | this._loadError = error; 67 | } 68 | else { 69 | try { 70 | this._data = JSON.parse(content); 71 | console.log("Loaded in " + (Date.now() - start) + "ms"); 72 | } 73 | catch (ex) { 74 | this._loadError = ex; 75 | console.log("Failed to load in " + (Date.now() - start) + "ms"); 76 | } 77 | } 78 | 79 | this._loading = false; 80 | 81 | var callbacks = this._loadCallbacks; 82 | this._loadCallbacks = []; 83 | callbacks.forEach(function(callback) { 84 | callback(this._loadError, this._data); 85 | }.bind(this)); 86 | }.bind(this)); 87 | } 88 | }, 89 | 90 | project: function(destinationProjection) { 91 | this._project(destinationProjection); 92 | }, 93 | 94 | _project: function(mapProjection) { 95 | var doBounds = !this._projectedData[mapProjection]; 96 | 97 | if (this._projection !== mapProjection) { 98 | console.log("Projecting features..."); 99 | start = Date.now(); 100 | 101 | this._projectedData[mapProjection] = projector.project.FeatureCollection(this._projection, mapProjection, this._data); 102 | 103 | console.log("Projected in " + (Date.now() - start) + "ms"); 104 | } else { 105 | console.log("Projection not necessary") 106 | this._projectedData[mapProjection] = this._data; 107 | } 108 | 109 | // HACK 110 | if (FILTER_BY_EXTENTS && doBounds) { 111 | this._calculateBounds(this._projectedData[mapProjection]); 112 | } 113 | }, 114 | 115 | _calculateBounds: function(dataset) { 116 | return this._shapes(dataset).forEach(function(shape) { 117 | shape.bounds = this._shapeBounds(shape); 118 | }, this); 119 | }, 120 | 121 | _filterByExtent: function(dataset, minX, minY, maxX, maxY) { 122 | if (!FILTER_BY_EXTENTS) { 123 | return dataset; 124 | } 125 | 126 | var extent = { 127 | minX: minX, 128 | minY: minY, 129 | maxX: maxX, 130 | maxY: maxY 131 | }; 132 | 133 | return this._shapes(dataset).filter(function(shape) { 134 | // return intersects(this._shapeBounds(shape), extent); 135 | return intersects(shape.bounds, extent); 136 | }.bind(this)); 137 | }, 138 | 139 | _shapeBounds: function(shape) { 140 | shape = shape.geometry || shape; 141 | var coordinates = shape.coordinates; 142 | 143 | if (shape.type === "Point") { 144 | return { 145 | minX: coordinates[0], 146 | maxX: coordinates[0], 147 | minY: coordinates[1], 148 | maxY: coordinates[1] 149 | }; 150 | } 151 | 152 | var bounds = { 153 | minX: Infinity, 154 | minY: Infinity, 155 | maxX: -Infinity, 156 | maxY: -Infinity 157 | }; 158 | 159 | if (shape.type === "Polygon" || shape.type === "MultiLineString") { 160 | for (var i = coordinates.length - 1; i >= 0; i--) { 161 | var coordinateSet = coordinates[i]; 162 | for (var j = coordinateSet.length - 1; j >= 0; j--) { 163 | bounds.minX = Math.min(bounds.minX, coordinateSet[j][0]); 164 | bounds.maxX = Math.max(bounds.maxX, coordinateSet[j][0]); 165 | bounds.minY = Math.min(bounds.minY, coordinateSet[j][1]); 166 | bounds.maxY = Math.max(bounds.maxY, coordinateSet[j][1]); 167 | } 168 | } 169 | } 170 | else if (shape.type === "MultiPolygon") { 171 | for (var i = coordinates.length - 1; i >= 0; i--) { 172 | var coordinateSet = coordinates[i]; 173 | for (var j = coordinateSet.length - 1; j >= 0; j--) { 174 | var coordinateSetSet = coordinateSet[j]; 175 | for (var k = coordinateSetSet.length - 1; k >= 0; k--) { 176 | bounds.minX = Math.min(bounds.minX, coordinateSetSet[k][0]); 177 | bounds.maxX = Math.max(bounds.maxX, coordinateSetSet[k][0]); 178 | bounds.minY = Math.min(bounds.minY, coordinateSetSet[k][1]); 179 | bounds.maxY = Math.max(bounds.maxY, coordinateSetSet[k][1]); 180 | } 181 | } 182 | } 183 | } 184 | else { 185 | for (var i = coordinates.length - 1; i >= 0; i--) { 186 | bounds.minX = Math.min(bounds.minX, coordinates[i][0]); 187 | bounds.maxX = Math.max(bounds.maxX, coordinates[i][0]); 188 | bounds.minY = Math.min(bounds.minY, coordinates[i][1]); 189 | bounds.maxY = Math.max(bounds.maxY, coordinates[i][1]); 190 | } 191 | } 192 | 193 | return bounds; 194 | }, 195 | 196 | _shapes: function(feature) { 197 | var shapes = []; 198 | if (feature.type === "FeatureCollection") { 199 | for (var i = feature.features.length - 1; i >= 0; i--) { 200 | shapes = shapes.concat(this._shapes(feature.features[i])); 201 | } 202 | } 203 | else if (feature.type === "Feature") { 204 | if (feature.geometry.type === "GeometryCollection") { 205 | shapes = shapes.concat(this._shapes(feature.geometry)); 206 | } 207 | else { 208 | shapes.push(feature); 209 | } 210 | } 211 | else if (feature.type === "GeometryCollection") { 212 | for (var i = feature.geometries.length - 1; i >= 0; i--) { 213 | shapes = shapes.concat(this._shapes(feature.geometries[i])); 214 | } 215 | } 216 | else { 217 | shapes.push(feature); 218 | } 219 | 220 | return shapes; 221 | } 222 | } 223 | 224 | module.exports = GeoJsonSource; 225 | 226 | var intersects = function(a, b) { 227 | var xIntersects = (a.minX < b.maxX && a.minX > b.minX) || 228 | (a.maxX < b.maxX && a.maxX > b.minX) || 229 | (a.minX < b.minX && a.maxX > b.maxX); 230 | 231 | var yIntersects = (a.minY < b.maxY && a.minY > b.minY) || 232 | (a.maxY < b.maxY && a.maxY > b.minY) || 233 | (a.minY < b.minY && a.maxY > b.maxY); 234 | 235 | return xIntersects && yIntersects; 236 | }; 237 | -------------------------------------------------------------------------------- /datasources/Shp.js: -------------------------------------------------------------------------------- 1 | var Shp = require('shp'); 2 | var projector = require(__dirname + "/../lib/projector"); 3 | 4 | var FILTER_BY_EXTENTS = true; 5 | 6 | var ShpSource = function(options) { 7 | this._projection = projector.util.cleanProjString(options.projection || "EPSG:4326"); 8 | this._path = options.path; // required 9 | 10 | this.name = options.name || options.path.slice(options.path.lastIndexOf("/") + 1); 11 | if (this.name.indexOf(".") !== -1) { 12 | this.name = this.name.slice(0, this.name.indexOf(".")); 13 | } 14 | this.sourceName = this.name; 15 | 16 | // loading synchronization 17 | this._loadCallbacks = []; 18 | this._loading = false; 19 | 20 | // stored state 21 | this._loadError = null; 22 | this._data = null; 23 | this._projectedData = {}; 24 | 25 | }; 26 | ShpSource.prototype = { 27 | constructor: ShpSource, 28 | 29 | getShapes: function(minX, minY, maxX, maxY, mapProjection, callback) { 30 | if (this._projectedData[mapProjection]){ 31 | var data = this._filterByExtent(this._projectedData[mapProjection], minX, minY, maxX, maxY); 32 | callback(null, data); 33 | } 34 | else { 35 | this.load(function(error, data) { 36 | if (error){ 37 | this._loadError = error; 38 | } 39 | else if (!this._projectedData[mapProjection]) { 40 | this._project(mapProjection); 41 | } 42 | 43 | var data = this._filterByExtent(this._projectedData[mapProjection], minX, minY, maxX, maxY); 44 | callback(this._loadError, data); 45 | }.bind(this)); 46 | } 47 | }, 48 | 49 | load: function(callback) { 50 | if (this._data || this._loadError) { 51 | callback(this._loadError, this._data); 52 | return; 53 | } 54 | 55 | callback && this._loadCallbacks.push(callback); 56 | 57 | if (!this._loading) { 58 | this._loading = true; 59 | 60 | var start = Date.now(); 61 | console.log("Loading data in " + this._path + "..."); 62 | 63 | Shp.readFile(this._path, function(error, content) { 64 | if (error) { 65 | this._loadError = error; 66 | console.log("Failed to load in " + (Date.now() - start) + "ms"); 67 | } else { 68 | this._data = content; 69 | console.log("Loaded in " + (Date.now() - start) + "ms"); 70 | } 71 | this._loading = false; 72 | 73 | var callbacks = this._loadCallbacks; 74 | this._loadCallbacks = []; 75 | callbacks.forEach(function(callback) { 76 | callback(this._loadError, this._data); 77 | }.bind(this)); 78 | }.bind(this)); 79 | } 80 | }, 81 | 82 | project: function(destinationProjection) { 83 | this._project(destinationProjection); 84 | }, 85 | 86 | _project: function(mapProjection) { 87 | var doBounds = !this._projectedData[mapProjection]; 88 | 89 | if (this._projection !== mapProjection) { 90 | console.log("Projecting features..."); 91 | start = Date.now(); 92 | 93 | this._projectedData[mapProjection] = projector.project.FeatureCollection(this._projection, mapProjection, this._data); 94 | 95 | console.log("Projected in " + (Date.now() - start) + "ms"); 96 | } else { 97 | console.log("Projection not necessary") 98 | this._projectedData[mapProjection] = this._data; 99 | } 100 | 101 | // HACK 102 | if (FILTER_BY_EXTENTS && doBounds) { 103 | this._calculateBounds(this._projectedData[mapProjection]); 104 | } 105 | }, 106 | 107 | _calculateBounds: function(dataset) { 108 | return this._shapes(dataset).forEach(function(shape) { 109 | shape.bounds = this._shapeBounds(shape); 110 | }, this); 111 | }, 112 | 113 | _filterByExtent: function(dataset, minX, minY, maxX, maxY) { 114 | if (!FILTER_BY_EXTENTS) { 115 | return dataset; 116 | } 117 | 118 | var extent = { 119 | minX: minX, 120 | minY: minY, 121 | maxX: maxX, 122 | maxY: maxY 123 | }; 124 | 125 | return this._shapes(dataset).filter(function(shape) { 126 | // return intersects(this._shapeBounds(shape), extent); 127 | return intersects(shape.bounds, extent); 128 | }.bind(this)); 129 | }, 130 | 131 | _shapeBounds: function(shape) { 132 | shape = shape.geometry || shape; 133 | var coordinates = shape.coordinates; 134 | 135 | if (shape.type === "Point") { 136 | return { 137 | minX: coordinates[0], 138 | maxX: coordinates[0], 139 | minY: coordinates[1], 140 | maxY: coordinates[1] 141 | }; 142 | } 143 | 144 | var bounds = { 145 | minX: Infinity, 146 | minY: Infinity, 147 | maxX: -Infinity, 148 | maxY: -Infinity 149 | }; 150 | 151 | if (shape.type === "Polygon" || shape.type === "MultiLineString") { 152 | for (var i = coordinates.length - 1; i >= 0; i--) { 153 | var coordinateSet = coordinates[i]; 154 | for (var j = coordinateSet.length - 1; j >= 0; j--) { 155 | bounds.minX = Math.min(bounds.minX, coordinateSet[j][0]); 156 | bounds.maxX = Math.max(bounds.maxX, coordinateSet[j][0]); 157 | bounds.minY = Math.min(bounds.minY, coordinateSet[j][1]); 158 | bounds.maxY = Math.max(bounds.maxY, coordinateSet[j][1]); 159 | } 160 | } 161 | } 162 | else if (shape.type === "MultiPolygon") { 163 | for (var i = coordinates.length - 1; i >= 0; i--) { 164 | var coordinateSet = coordinates[i]; 165 | for (var j = coordinateSet.length - 1; j >= 0; j--) { 166 | var coordinateSetSet = coordinateSet[j]; 167 | for (var k = coordinateSetSet.length - 1; k >= 0; k--) { 168 | bounds.minX = Math.min(bounds.minX, coordinateSetSet[k][0]); 169 | bounds.maxX = Math.max(bounds.maxX, coordinateSetSet[k][0]); 170 | bounds.minY = Math.min(bounds.minY, coordinateSetSet[k][1]); 171 | bounds.maxY = Math.max(bounds.maxY, coordinateSetSet[k][1]); 172 | } 173 | } 174 | } 175 | } 176 | else { 177 | for (var i = coordinates.length - 1; i >= 0; i--) { 178 | bounds.minX = Math.min(bounds.minX, coordinates[i][0]); 179 | bounds.maxX = Math.max(bounds.maxX, coordinates[i][0]); 180 | bounds.minY = Math.min(bounds.minY, coordinates[i][1]); 181 | bounds.maxY = Math.max(bounds.maxY, coordinates[i][1]); 182 | } 183 | } 184 | 185 | return bounds; 186 | }, 187 | 188 | _shapes: function(feature) { 189 | var shapes = []; 190 | if (feature.type === "FeatureCollection") { 191 | for (var i = feature.features.length - 1; i >= 0; i--) { 192 | shapes = shapes.concat(this._shapes(feature.features[i])); 193 | } 194 | } 195 | else if (feature.type === "Feature") { 196 | if (feature.geometry.type === "GeometryCollection") { 197 | shapes = shapes.concat(this._shapes(feature.geometry)); 198 | } 199 | else { 200 | shapes.push(feature); 201 | } 202 | } 203 | else if (feature.type === "GeometryCollection") { 204 | for (var i = feature.geometries.length - 1; i >= 0; i--) { 205 | shapes = shapes.concat(this._shapes(feature.geometries[i])); 206 | } 207 | } 208 | else { 209 | shapes.push(feature); 210 | } 211 | 212 | return shapes; 213 | } 214 | } 215 | 216 | module.exports = ShpSource; 217 | 218 | var intersects = function(a, b) { 219 | var xIntersects = (a.minX < b.maxX && a.minX > b.minX) || 220 | (a.maxX < b.maxX && a.maxX > b.minX) || 221 | (a.minX < b.minX && a.maxX > b.maxX); 222 | 223 | var yIntersects = (a.minY < b.maxY && a.minY > b.minY) || 224 | (a.maxY < b.maxY && a.maxY > b.minY) || 225 | (a.minY < b.minY && a.maxY > b.maxY); 226 | 227 | return xIntersects && yIntersects; 228 | }; 229 | 230 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Map = require(__dirname + '/lib/Map'), 2 | GeoJson = require(__dirname + '/datasources/GeoJson'), 3 | Shp = require(__dirname + '/datasources/Shp'), 4 | projector = require(__dirname + '/lib/projector'), 5 | routes = require(__dirname + '/lib/routes'), 6 | UTFGrid = require(__dirname + '/lib/utfgrid'); 7 | 8 | module.exports = { 9 | /** 10 | * The central map component 11 | */ 12 | Map: Map, 13 | /** 14 | * Datasources are responsible for fetching and returning data 15 | */ 16 | datasources: { 17 | GeoJson: GeoJson, 18 | Shp: Shp 19 | }, 20 | /** 21 | * Transform between projections 22 | */ 23 | projector: projector, 24 | 25 | /** 26 | * Routing Middleware 27 | */ 28 | route: routes 29 | } 30 | -------------------------------------------------------------------------------- /lib/Map.js: -------------------------------------------------------------------------------- 1 | var async = require("async"); 2 | var __ = require("lodash"); 3 | var renderer = require("./renderer"); 4 | var projector = require("./projector"); 5 | var cartoRenderer = require("./cartoRenderer"); 6 | 7 | var BUFFER_RATIO = 0.25; 8 | 9 | /** 10 | * var map = new Map(); 11 | * map.addData(function(minX, minY, maxX, maxY, projection) { ... }); 12 | * map.setStyle(...); 13 | * map.render(0, 0, 180, 90, 500, 250); 14 | */ 15 | 16 | // default to EPSG:3857 (web mercator) 17 | // http://spatialreference.org/ref/sr-org/7483/ 18 | var DEFAULT_PROJECTION = "EPSG:900913";//"+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs"; 19 | 20 | var Map = function(options) { 21 | options = options || {}; 22 | 23 | this.datasources = []; 24 | this.styles = []; 25 | this.projection = DEFAULT_PROJECTION; 26 | this.assetsPath = "."; 27 | 28 | if (options.projection){ 29 | this.projection = projector.util.cleanProjString(options.projection); 30 | console.log(this.projection); 31 | } 32 | 33 | this.boundsBuffer = 34 | ("boundsBuffer" in options) ? options.boundsBuffer : BUFFER_RATIO; 35 | 36 | this._renderer = cartoRenderer; 37 | }; 38 | 39 | Map.prototype = { 40 | constructor: Map, 41 | 42 | render: function(options) { 43 | this._getData(options.bounds, options.boundsBuffer, function(error, shapes) { 44 | if (error) { 45 | options.callback(error); 46 | } 47 | else { 48 | this._renderer.renderImage(__.extend({}, options, { 49 | layers: shapes, 50 | styles: this.processedStyles, 51 | callback: function(error, canvas) { 52 | options.callback && options.callback(error, canvas); 53 | } 54 | })); 55 | } 56 | }.bind(this)); 57 | }, 58 | 59 | // Should this really be here, or should it exist on a different object entirely? 60 | renderGrid: function(options) {//minX, minY, maxX, maxY, width, height, asImage, callback) { 61 | this._getData(options.bounds, options.boundsBuffer, function(error, shapes) { 62 | if (error) { 63 | options.callback(error); 64 | console.error("ERROR! "+error); 65 | } 66 | else { 67 | // this._renderer.renderGrid(minX, minY, maxX, maxY, width, height, shapes, this.processedStyles, asImage, callback); 68 | this._renderer.renderGrid(__.extend({}, options, { 69 | layers: shapes, 70 | styles: this.processedStyles, 71 | callback: function(error, canvas) { 72 | options.callback && options.callback(error, canvas); 73 | } 74 | })); 75 | } 76 | }.bind(this)); 77 | }, 78 | 79 | _getData: function(bounds, buffer, callback) { 80 | var dataBounds = this._bufferedBounds(bounds, buffer); 81 | 82 | // this is a bit quick and dirty - we could possibly use style data 83 | // to figure out more detailed queries than just geographic bounds 84 | var projection = this.projection; 85 | var self = this; 86 | async.map( 87 | this.datasources, 88 | function(datasource, dataCallback) { 89 | var sourceName = datasource.sourceName; 90 | var preCallback = function(error, data) { 91 | if (!error) { 92 | data.source = sourceName; 93 | } 94 | dataCallback(error, data); 95 | }; 96 | 97 | if (typeof datasource !== "function") { 98 | datasource = datasource.getShapes.bind(datasource); 99 | } 100 | 101 | // allow simple sources to just return results immediately 102 | var syncData = datasource(dataBounds.minX, dataBounds.minY, dataBounds.maxX, dataBounds.maxY, projection, preCallback); 103 | if (syncData) { 104 | preCallback(null, syncData); 105 | } 106 | }, 107 | callback 108 | // HACK: this is temporary until all the style machinery is done 109 | // should really be the above line 110 | // function(error, data) { 111 | // if (!error) { 112 | // data.forEach(function(collection, index) { 113 | // collection.styles = [self.styles[index].properties]; 114 | // }); 115 | // } 116 | // callback(error, data); 117 | // } 118 | ); 119 | }, 120 | 121 | _bufferedBounds: function(bounds, buffer) { 122 | if (buffer == null) { 123 | buffer = this.boundsBuffer; 124 | } 125 | 126 | if (typeof buffer === "function") { 127 | return buffer.call(this, bounds); 128 | } 129 | 130 | if (typeof buffer !== "number") { 131 | buffer = BUFFER_RATIO; 132 | } 133 | 134 | amount = (bounds.maxX - bounds.minX) * buffer; 135 | return { 136 | minX: bounds.minX - amount, 137 | minY: bounds.minY - amount, 138 | maxX: bounds.maxX + amount, 139 | maxY: bounds.maxY + amount 140 | }; 141 | }, 142 | 143 | addData: function(datasource) { 144 | // validate datasource 145 | if (!(typeof datasource === "function" || typeof datasource.getShapes === "function")) { 146 | console.warn("Datasource is not a function or an object with a 'getShapes()' function."); 147 | return false; 148 | } 149 | 150 | var index = this.datasources.indexOf(datasource); 151 | if (index === -1) { 152 | this.datasources.push(datasource); 153 | return true; 154 | } 155 | return false; 156 | }, 157 | 158 | removeData: function(datasource) { 159 | var index = this.datasources.indexOf(datasource); 160 | if (index > -1) { 161 | this.datasources.splice(index, 1); 162 | return true; 163 | } 164 | return false; 165 | }, 166 | 167 | setProjection: function(projection) { 168 | // TODO: validate this somehow? 169 | this.projection = projection; 170 | }, 171 | 172 | addStyle: function(style) { 173 | // may need to do better flattening, etc. 174 | if (Object.prototype.toString.call(style) === "[object Array]") { 175 | this.styles = this.styles.concat(style); 176 | } 177 | else { 178 | this.styles.push(style); 179 | } 180 | this._processStyles(); 181 | }, 182 | 183 | setRenderer: function(renderer) { 184 | if (renderer.renderImage && renderer.renderGrid && renderer.processStyles) { 185 | this._renderer = renderer; 186 | this._processStyles(); 187 | } 188 | }, 189 | 190 | /** 191 | * Triggers all the map's datasources to prepare themselves. Usually this 192 | * connecting to a database, loading and processing a file, etc. 193 | * Calling this method is completely optional, but allows you to speed up 194 | * rendering of the first tile. 195 | */ 196 | prepare: function() { 197 | var projection = this.projection; 198 | 199 | this.datasources.forEach(function(datasource) { 200 | datasource.load && datasource.load(function(error) { 201 | datasource.project && datasource.project(projection); 202 | }); 203 | }); 204 | }, 205 | 206 | _processStyles: function() { 207 | this.processedStyles = this._renderer.processStyles(this.styles, this.assetsPath); 208 | } 209 | }; 210 | 211 | module.exports = Map; 212 | -------------------------------------------------------------------------------- /lib/cartoRenderer.js: -------------------------------------------------------------------------------- 1 | var Canvas = require('canvas'); 2 | var carto = require("carto"); 3 | var UTFGrid = require('./utfgrid'); 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | var util = require('util'); 7 | var __ = require('lodash'); 8 | 9 | var MIGURSKI = true; 10 | 11 | var MAX_ZOOM = 23; 12 | var LINE_DASH_PROPERTY = (function() { 13 | var canvas = new Canvas(8, 8); 14 | var ctx = canvas.getContext("2d"); 15 | var property = (ctx.dash && "dash") || (ctx.lineDash && "lineDash"); 16 | if (!property) { 17 | for (var prefixes = ["webkit", "moz", "o", "ms"], i = prefixes.length - 1; i > -1; i--) { 18 | var prefix = prefixes[i]; 19 | if (ctx[prefix + "Dash"]) return prefix + "Dash" 20 | else if (ctx[prefix + "LineDash"]) return prefix + "LineDash"; 21 | } 22 | } 23 | return property; 24 | })(); 25 | 26 | // Handles canvas and context management, then passes things off to an actual rendering routine 27 | // TODO: support arbitrary canvas size (or at least @2x size) 28 | // TODO: support arbitrary render routines 29 | var renderImage = exports.renderImage = function(options) { 30 | var bounds = options.bounds; 31 | var width = options.width; 32 | var height = options.height; 33 | var layers = options.layers; 34 | var styles = options.styles; 35 | var callback = options.callback; 36 | var zoom = options.zoom; 37 | 38 | var canvas = new Canvas(width, height), 39 | ctx = canvas.getContext('2d'), 40 | // we're using the same ratio for width and height, so the actual maxY may not match specified maxY... 41 | pxPtRatio = width / (bounds.maxX - bounds.minX); 42 | 43 | // once upon a time, we used to scale and translate the canvas instead of transforming the points. 44 | // However, this causes problems when drawing images and patterns, so we can't do that anymore :( 45 | transform = function(p) { 46 | var point = []; 47 | point[0] = (p[0] - bounds.minX) * pxPtRatio; 48 | point[1] = ((p[1] - bounds.minY) * -pxPtRatio) + height; 49 | return point; 50 | }; 51 | 52 | cartoImageRenderer(ctx, pxPtRatio, options.layers, options.styles, options.zoom, bounds.minX, bounds.maxX, bounds.minY); 53 | 54 | options.callback && options.callback(null, canvas); 55 | }; 56 | 57 | var renderGrid = exports.renderGrid = function(options) { 58 | var bounds = options.bounds; 59 | var width = options.width; 60 | var height = options.height; 61 | var callback = options.callback; 62 | var featureImage = options.drawImage; 63 | 64 | var canvas = new Canvas(width, width), 65 | ctx = canvas.getContext('2d'), 66 | // we're using the same ratio for width and height, so the actual maxY may not match specified maxY... 67 | pxPtRatio = width / (bounds.maxX - bounds.minX), 68 | gridSize = width; 69 | 70 | transform = function(p) { 71 | var point = []; 72 | point[0] = (p[0] - bounds.minX) * pxPtRatio; 73 | point[1] = ((p[1] - bounds.minY) * -pxPtRatio) + height; 74 | return point; 75 | }; 76 | 77 | ctx.antialias = 'none'; 78 | ctx.fillStyle = '#000000'; // Paint it black 79 | ctx.fillRect(0, 0, gridSize, gridSize); 80 | 81 | // renderer is provided somehow (but we'll have a simple default) 82 | var colorIndex = cartoGridRenderer(ctx, pxPtRatio, options.layers, options.styles, options.zoom); 83 | 84 | // return the image we just rendered instead of the actual grid (for debugging) 85 | if (featureImage) { 86 | callback(undefined, canvas); 87 | } 88 | else { 89 | var pixels = ctx.getImageData(0, 0, gridSize, gridSize).data; // array of all pixels 90 | var utfgrid = (new UTFGrid(gridSize, function (point) { 91 | // Use our raster (ctx) and colorIndex to lookup the corresponding feature 92 | 93 | //look up the the rgba values for the pixel at x,y 94 | // scan rows and columns; each pixel is 4 separate values (R,G,B,A) in the array 95 | var startPixel = (gridSize * point.y + point.x) * 4; 96 | 97 | // convert those rgba elements to hex then an integer 98 | var intColor = h2d(d2h(pixels[startPixel], 2) + d2h(pixels[startPixel + 1], 2) + d2h(pixels[startPixel + 2], 2)); 99 | 100 | return colorIndex[intColor]; // returns the feature that's referenced in colorIndex. 101 | })).encodeAsObject(); 102 | 103 | for(var featureId in utfgrid.data) { 104 | utfgrid.data[featureId] = utfgrid.data[featureId].properties; 105 | } 106 | 107 | callback(undefined, utfgrid); 108 | } 109 | }; 110 | 111 | // NB:- these functions are called using 'this' as our canvas context 112 | // it's not clear to me whether this architecture is right but it's neat ATM. 113 | var transform = null; 114 | var renderPath = { 115 | 'MultiPolygon': function(mp) { 116 | mp.forEach(renderPath.Polygon, this); 117 | }, 118 | 'Polygon': function(p) { 119 | p.forEach(renderPath.LineString, this); 120 | }, 121 | 'MultiLineString': function(ml) { 122 | ml.forEach(renderPath.LineString, this); 123 | }, 124 | 'LineString': function(l) { 125 | var start = l[0]; 126 | if (transform) { 127 | start = transform(start); 128 | } 129 | this.moveTo(start[0], start[1]); 130 | l.slice(1).forEach(function(c){ 131 | if (transform) { 132 | c = transform(c); 133 | } 134 | this.lineTo(c[0], c[1]); 135 | }, this); 136 | }, 137 | 'MultiPoint': function(p, scale) { 138 | // Can't use forEach here because we need to pass scale along 139 | for (var i = 0, len = p.length; i < len; i++) { 140 | renderPath.Point.call(this, p[i], scale); 141 | } 142 | }, 143 | 'Point': function(p, scale) { 144 | if (transform) { 145 | p = transform(p); 146 | this.arc(p[0], p[1], 8, 0, Math.PI * 2, true); 147 | } 148 | else { 149 | this.arc(p[0], p[1], 8 / scale, 0, Math.PI * 2, true); 150 | } 151 | } 152 | }; 153 | 154 | var roundPoint = function(point) { 155 | return point.map(Math.round); 156 | }; 157 | 158 | // Many canvas implementations don't support the dash/lineDash property, so we do it by hand :\ 159 | // NOTE also lineDash is in the WHATWG HTML draft but not W3C: 160 | // http://www.whatwg.org/specs/web-apps/current-work/multipage/the-canvas-element.html 161 | var renderDashedPath = { 162 | 'MultiPolygon': function(dashArray, mp) { 163 | mp.forEach(renderDashedPath.Polygon.bind(this, dashArray)); 164 | }, 165 | 'Polygon': function(dashArray, p) { 166 | p.forEach(renderDashedPath.LineString.bind(this, dashArray)); 167 | }, 168 | 'MultiLineString': function(dashArray, ml) { 169 | ml.forEach(renderDashedPath.LineString.bind(this, dashArray)); 170 | }, 171 | 'LineString': function(dashArray, l, offset) { 172 | // if there's no dashArray, just go render a solid line 173 | if (!dashArray) { 174 | return renderPath.LineString.call(this, l); 175 | } 176 | 177 | offset = offset || 0; 178 | 179 | // don't render segments less than this length (in px) 180 | // for best fidelity, this should be at least 1, but not much higher 181 | var minSegmentLength = 2; 182 | 183 | // round off the start and end points to get as close as we can to drawing on pixel boundaries 184 | // loop through line combining segments until they match the minimum length 185 | var start = roundPoint(transform(l[0])); 186 | for (var i = 1, len = l.length; i < len; i++) { 187 | var end = roundPoint(transform(l[i])), 188 | dx = end[0] - start[0]; 189 | dy = end[1] - start[1]; 190 | lineLength = Math.sqrt(dx * dx + dy * dy); 191 | 192 | // only draw segments of 1px or greater 193 | if (lineLength >= minSegmentLength) { 194 | var angle = Math.atan2(dy, dx); 195 | offset = renderDashedPath._screenLine.call(this, dashArray, start, end, lineLength, angle, offset); 196 | start = end; 197 | } 198 | } 199 | 200 | }, 201 | _screenLine: function(dashArray, start, end, realLength, angle, offset) { 202 | // we're gonna do some transforms 203 | this.save(); 204 | 205 | // move the line out by half a pixel for more crisp 1px drawing 206 | var yOffset = -0.5; 207 | 208 | // In order to reduce artifacts of trying to draw fractions of a pixel, 209 | // only draw even pixels worth of length 210 | var length = Math.floor(realLength); 211 | 212 | // Skip zero length/less-than-one length lines 213 | if (length === 0) { 214 | return offset; 215 | } 216 | 217 | // decimal offset is left over from refraining from drawing fractions of a pixel on a previous segment 218 | // (see where the length is floor()'d above) 219 | // We'll eventually move the start point back by the fractional offset to account for what we 220 | // didn't draw in the previous segment (we potentially underdraw because we floor()'d the length). 221 | var intOffset = Math.ceil(offset); 222 | var decOffset = offset - intOffset; 223 | offset = intOffset; 224 | 225 | // transform the context so we can simplify the work by pretending to draw a straight line 226 | this.translate(start[0], start[1]); 227 | this.rotate(angle); 228 | 229 | // Move the start point back by the fractional offset (see deeper description above) 230 | this.moveTo(decOffset, yOffset); 231 | 232 | var dashCount = dashArray.length; 233 | var dashIndex = 0; 234 | // Move the start point by the integer offset (the fractional bit is already accounted for above) 235 | var x = offset || 0; 236 | // keep track of how much of the pattern we drew (used to offset the next segment) 237 | var patternDistance = 0; 238 | var draw = true; 239 | 240 | while (x < length) { 241 | // reset the pattern distance when we loop back to the start of the dash array 242 | if (dashIndex === 0) { 243 | patternDistance = 0; 244 | } 245 | 246 | // get the distance of this dash 247 | var dashLength = dashArray[dashIndex]; 248 | dashIndex = (dashIndex + 1) % dashCount; 249 | x += dashLength; 250 | patternDistance += dashLength; 251 | 252 | // if we are about to draw past the end of the segment, don't 253 | if (x > length) { 254 | patternDistance += length - x; 255 | x = length; 256 | } 257 | 258 | // only draw once we've moved past the offset 259 | if (x > 0) { 260 | if (draw) { 261 | this.lineTo(x, yOffset); 262 | } 263 | else { 264 | this.moveTo(x, yOffset); 265 | } 266 | } 267 | draw = !draw; 268 | } 269 | // Add the fractional extra distance that we didn't draw back in 270 | patternDistance += realLength - length; 271 | 272 | this.restore(); 273 | return -patternDistance; 274 | }, 275 | 'MultiPoint': function(dashArray, p, scale) { 276 | // Can't use forEach here because we need to pass scale along 277 | for (var i = 0, len = p.length; i < len; i++) { 278 | renderDashedPath.Point.call(this, null, p[i], scale); 279 | } 280 | }, 281 | 'Point': function(dashArray, p, scale) { 282 | if (transform) { 283 | p = transform(p); 284 | this.arc(p[0], p[1], 8, 0, Math.PI * 2, true); 285 | } 286 | else { 287 | this.arc(p[0], p[1], 8 / scale, 0, Math.PI * 2, true); 288 | } 289 | } 290 | }; 291 | 292 | var renderImage = { 293 | 'MultiPolygon': function(image, mp) { 294 | mp.forEach(renderImage.Polygon.bind(this, image)); 295 | }, 296 | 'Polygon': function(image, p) { 297 | renderImage.LineString.call(this, image, p[0]); 298 | }, 299 | 'MultiLineString': function(image, ml) { 300 | ml.forEach(renderImage.LineString.bind(this, image)); 301 | }, 302 | 'LineString': function(image, l) { 303 | // put the point at the center 304 | var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 305 | l.forEach(function(point) { 306 | minX = Math.min(minX, point[0]); 307 | minY = Math.min(minY, point[1]); 308 | maxX = Math.max(maxX, point[0]); 309 | maxY = Math.max(maxY, point[1]); 310 | }); 311 | return renderImage.Point.call(this, image, [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2]) 312 | }, 313 | 'MultiPoint': function(image, p, scale) { 314 | // Can't use forEach here because we need to pass scale along 315 | for (var i = 0, len = p.length; i < len; i++) { 316 | renderImage.Point.call(this, image, p[i], scale); 317 | } 318 | }, 319 | 'Point': function(image, p, scale) { 320 | if (transform) { 321 | p = transform(p); 322 | } 323 | this.drawImage(image, p[0] - image.width / 2, p[1] - image.height / 2); 324 | } 325 | }; 326 | 327 | var renderDot = { 328 | 'MultiPolygon': function(radius, mp) { 329 | mp.forEach(renderDot.Polygon.bind(this, radius)); 330 | }, 331 | 'Polygon': function(radius, p) { 332 | renderDot.LineString.call(this, radius, p[0]); 333 | }, 334 | 'MultiLineString': function(radius, ml) { 335 | ml.forEach(renderDot.LineString.bind(this, radius)); 336 | }, 337 | 'LineString': function(radius, l) { 338 | // put the point at the center 339 | var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 340 | l.forEach(function(point) { 341 | minX = Math.min(minX, point[0]); 342 | minY = Math.min(minY, point[1]); 343 | maxX = Math.max(maxX, point[0]); 344 | maxY = Math.max(maxY, point[1]); 345 | }); 346 | return renderDot.Point.call(this, radius, [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2]) 347 | }, 348 | 'MultiPoint': function(radius, p, scale) { 349 | // Can't use forEach here because we need to pass scale along 350 | for (var i = 0, len = p.length; i < len; i++) { 351 | renderDot.Point.call(this, radius, p[i], scale); 352 | } 353 | }, 354 | 'Point': function(radius, p) { 355 | if (transform) { 356 | p = transform(p); 357 | } 358 | this.arc(p[0], p[1], radius || 10, 0, Math.PI * 2, true); 359 | } 360 | }; 361 | 362 | var renderText = { 363 | 'MultiPolygon': function(text, mp) { 364 | mp.forEach(renderText.Polygon.bind(this, text)); 365 | }, 366 | 'Polygon': function(text, p) { 367 | renderText.LineString.call(this, text, p[0]); 368 | }, 369 | 'MultiLineString': function(text, ml) { 370 | ml.forEach(renderText.LineString.bind(this, text)); 371 | }, 372 | 'LineString': function(text, l) { 373 | // we support line and point, point is default 374 | if (text.placement === "line") { 375 | this.textAlign = "left"; 376 | 377 | var totalLength = 0; 378 | var segmentLengths = []; 379 | 380 | for (var i = 1, len = l.length; i < len; i++) { 381 | var start = transform(l[i - 1]); 382 | end = transform(l[i]), 383 | dx = end[0] - start[0]; 384 | dy = end[1] - start[1]; 385 | lineLength = Math.sqrt(dx * dx + dy * dy); 386 | 387 | totalLength += lineLength; 388 | segmentLengths.push(lineLength); 389 | } 390 | 391 | var closedShape = l[0][0] === l[len - 1][0] && l[0][1] === l[len - 1][1]; 392 | 393 | // NOTE: should we really bail here if there's not enough room on the line to write the text? 394 | // maybe this should be an option (on by default) 395 | var textLength = this.measureText(text.text).width; 396 | if (totalLength < textLength) { 397 | return; 398 | } 399 | 400 | // determine distance along line to start placing text 401 | var startPoint = 0; 402 | // TODO: support BIDI text (right now end = right and start = left) 403 | if (text.align === "end" || text.align === "right") { 404 | startPoint = totalLength - textLength; 405 | } 406 | else if (text.align === "center" || text.align === "middle" || !text.align) { 407 | startPoint = totalLength / 2 - textLength / 2; 408 | } 409 | 410 | // these operations may be destructive to the text object, so save old values here 411 | // TODO: maybe we should make a new object that uses the text argument as its prototype? 412 | var fullText = text.text; 413 | var originalXOffset = 0; 414 | if (text.offset) { 415 | originalXOffset = text.offset.x || 0; 416 | startPoint += originalXOffset; 417 | text.offset.x = 0; 418 | 419 | // if the line represents a closed shape, loop startPoint around the line 420 | if (closedShape) { 421 | while (startPoint < 0) { 422 | startPoint += totalLength; 423 | } 424 | 425 | while (startPoint > totalLength) { 426 | startPoint -= totalLength; 427 | } 428 | } 429 | } 430 | 431 | var segmentLengthsTotal = 0; 432 | 433 | for (var i = 0, len = segmentLengths.length; i < len; i++) { 434 | var segmentLength = segmentLengths[i]; 435 | if (segmentLengthsTotal + segmentLength >= startPoint || i === len - 1) { 436 | var segmentDistance = startPoint - segmentLengthsTotal; 437 | 438 | var start = transform(l[i]), 439 | end = transform(l[i + 1]), 440 | dx = end[0] - start[0], 441 | dy = end[1] - start[1], 442 | angle = Math.atan2(dy, dx), 443 | centerPoint = [segmentDistance * Math.cos(angle) + start[0], start[1] + segmentDistance * Math.sin(angle)]; 444 | 445 | // keep street names from going upside-down 446 | var flippedText = false; 447 | var flippedMinus = false; 448 | if (angle > 0.5 * Math.PI) { 449 | angle -= Math.PI; 450 | flippedText = true; 451 | flippedMinus = true; 452 | } 453 | else if (angle < -0.5 * Math.PI) { 454 | angle += Math.PI; 455 | flippedText = true; 456 | } 457 | 458 | this.save(); 459 | this.translate(centerPoint[0], centerPoint[1]); 460 | this.rotate(angle); 461 | 462 | if (MIGURSKI) { 463 | var textPixels = 0; 464 | var resetIndex = 0; 465 | var segmentOffset = 0; 466 | for (var j = 0, jLen = fullText.length; j < jLen; j++) { 467 | // GO BACKWARDS FOR FLIPPED TEXT 468 | if (flippedText) { 469 | 470 | if (segmentOffset + segmentDistance + textPixels > segmentLength && (i < len - 1 || closedShape)) { 471 | // TODO: Potentially add some spacing if the angle causes the text top to bend "in" 472 | segmentOffset = (segmentOffset + segmentDistance + textPixels) - segmentLength; 473 | i = (i + 1) % len; 474 | segmentLength = segmentLengths[i]; 475 | segmentDistance = 0; 476 | var start = transform(l[i]), 477 | end = transform(l[i + 1]), 478 | dx = end[0] - start[0], 479 | dy = end[1] - start[1], 480 | angle = Math.atan2(dy, dx); 481 | 482 | // keep street names from going upside-down 483 | if (flippedText) { 484 | if (flippedMinus) { 485 | angle -= Math.PI; 486 | } 487 | else { 488 | angle += Math.PI; 489 | } 490 | } 491 | 492 | this.restore(); 493 | this.save(); 494 | this.translate(start[0], start[1]); 495 | this.rotate(angle); 496 | textPixels = 0; 497 | resetIndex = j; 498 | } 499 | 500 | var textIndex = jLen - 1 - j; 501 | textPixels = this.measureText(fullText.slice(textIndex, jLen - resetIndex)).width; 502 | 503 | text.text = fullText[textIndex]; 504 | renderText.Point.call(this, text, [-(segmentOffset + textPixels), 0], true); 505 | } 506 | else { 507 | 508 | var textIndex = j; 509 | var textResetIndex = resetIndex; 510 | if (j > 0) { 511 | textPixels = this.measureText(fullText.slice(resetIndex, j)).width; 512 | } 513 | 514 | if (segmentOffset + segmentDistance + textPixels > segmentLength && (i < len - 1 || closedShape)) { 515 | // TODO: Potentially add some spacing if the angle causes the text top to bend "in" 516 | segmentOffset = (segmentOffset + segmentDistance + textPixels) - segmentLength; 517 | i = (i + 1) % len; 518 | segmentLength = segmentLengths[i]; 519 | segmentDistance = 0; 520 | var start = transform(l[i]), 521 | end = transform(l[i + 1]), 522 | dx = end[0] - start[0], 523 | dy = end[1] - start[1], 524 | angle = Math.atan2(dy, dx); 525 | 526 | this.restore(); 527 | this.save(); 528 | this.translate(start[0], start[1]); 529 | this.rotate(angle); 530 | textPixels = 0; 531 | resetIndex = j; 532 | } 533 | 534 | text.text = fullText[textIndex]; 535 | renderText.Point.call(this, text, [segmentOffset + textPixels, 0], true); 536 | } 537 | } 538 | } 539 | else { 540 | renderText.Point.call(this, text, [0, 0], true); 541 | } 542 | 543 | this.restore(); 544 | break; 545 | } 546 | segmentLengthsTotal += segmentLength; 547 | } 548 | 549 | // repair text object before returning 550 | text.text = fullText; 551 | text.offset.x = originalXOffset; 552 | } 553 | else { 554 | // put the point at the center 555 | var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 556 | l.forEach(function(point) { 557 | minX = Math.min(minX, point[0]); 558 | minY = Math.min(minY, point[1]); 559 | maxX = Math.max(maxX, point[0]); 560 | maxY = Math.max(maxY, point[1]); 561 | }); 562 | return renderText.Point.call(this, text, [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2]) 563 | } 564 | }, 565 | 'MultiPoint': function(text, p, scale) { 566 | // Can't use forEach here because we need to pass scale along 567 | for (var i = 0, len = p.length; i < len; i++) { 568 | renderText.Point.call(this, radius, p[i], scale); 569 | } 570 | }, 571 | 'Point': function(text, p, preTransformed) { 572 | if (transform && !preTransformed) { 573 | p = transform(p); 574 | } 575 | var x = p[0] + (text.offset ? text.offset.x : 0); 576 | var y = p[1] + (text.offset ? text.offset.y : 0); 577 | var textMethod = text.stroke ? "strokeText" : "fillText"; 578 | this[textMethod](text.text, x, y); 579 | } 580 | }; 581 | 582 | // var didATile = false; 583 | var cartoImageRenderer = function (ctx, scale, layers, styles, zoom, minX, maxX, minY) { 584 | // background first 585 | styles.forEach(function(style) { 586 | if (cartoSelectorIsMatch(style, null, null, zoom)) { 587 | style.rules.forEach(function(rule) { 588 | if (rule.name === "background-color") { 589 | ctx.fillStyle = rule.value.toString(); 590 | } 591 | else if (rule.name === "background-image") { 592 | if (!(rule.value instanceof Canvas.Image)) { 593 | var content = rule.value.toString(); 594 | var img = new Canvas.Image(); 595 | var imagePath = path.normalize(__dirname + "/../static/images/" + content); 596 | img.src = fs.readFileSync(imagePath); 597 | rule.value = img; 598 | } 599 | 600 | img = rule.value; 601 | ctx.fillStyle = ctx.createPattern(img, "repeat"); 602 | } 603 | }); 604 | ctx.fillRect(0,0,ctx.canvas.width, ctx.canvas.height); 605 | } 606 | }); 607 | 608 | // create list of attachments (in order) so that we can walk through and render them together for each layer 609 | // TODO: should be able to do this as part of the processing stage 610 | var attachments = styles.reduce(function(attachmentList, style) { 611 | if (style.attachment !== "__default__" && attachmentList.indexOf(style.attachment) === -1) { 612 | attachmentList.push(style.attachment); 613 | } 614 | return attachmentList; 615 | }, []); 616 | attachments.push("__default__"); 617 | 618 | layers.forEach(function(layer, i) { 619 | var source = layer.source; 620 | var features = layer.features || layer; 621 | 622 | attachments.forEach(function(attachment) { 623 | 624 | features.forEach(function(feature) { 625 | // get all the drawing rules for this attachment and feature 626 | var collapsedStyle = {}; 627 | var instanceOrder = []; 628 | styles.forEach(function(style) { 629 | if (style.attachment === attachment && cartoSelectorIsMatch(style, feature, source, zoom)) { 630 | style.rules.forEach(function(rule) { 631 | if (!collapsedStyle[rule.instance]) { 632 | collapsedStyle[rule.instance] = {}; 633 | instanceOrder.push(rule.instance); 634 | } 635 | if (!collapsedStyle[rule.instance].hasOwnProperty(rule.name)) { 636 | collapsedStyle[rule.instance][rule.name] = rule.value; 637 | } 638 | }); 639 | } 640 | }); 641 | 642 | var renderInstance = function(instanceName) { 643 | var instanceStyle = collapsedStyle[instanceName]; 644 | 645 | ctx.save(); 646 | 647 | var shouldFill = false, 648 | shouldStroke = false, 649 | shouldMark = false, 650 | shouldPoint = false, 651 | shouldText = false, 652 | dashedStroke = false; 653 | for (var key in instanceStyle) { 654 | var rawValue = instanceStyle[key], 655 | value = rawValue.toString(); 656 | 657 | if (key === "background-color" || key === "polygon-fill") { 658 | ctx.fillStyle = value; 659 | shouldFill = true; 660 | } 661 | else if (key === "background-image" || key === "polygon-pattern-file") { 662 | if (rawValue) { 663 | ctx.fillStyle = ctx.createPattern(rawValue, "repeat"); 664 | shouldFill = true; 665 | } 666 | } 667 | else if (key === "line-width") { 668 | ctx.lineWidth = parseInt(value); 669 | } 670 | else if (key === "line-color") { 671 | ctx.strokeStyle = value; 672 | shouldStroke = true; 673 | } 674 | else if (key === "line-opacity") { 675 | // handled at stroke time below 676 | } 677 | else if (key === "line-join") { 678 | ctx.lineJoin = value; 679 | } 680 | else if (key === "line-cap") { 681 | ctx.lineCap = value; 682 | } 683 | else if (key === "line-miterlimit") { 684 | ctx.miterLimit = value; 685 | } 686 | else if (key === "line-dasharray") { 687 | // TODO: dasharray support 688 | // console.log("Dasharray: ", value); 689 | var dashedStroke = value.split(",").map(parseFloat); 690 | // console.log(" now: ", dashArray); 691 | // ctx.dash = dashArray; 692 | // ctx.lineDash = dashArray; 693 | } 694 | else if (key === "polygon-opacity") { 695 | // handled at fill time below 696 | } 697 | else if (key === "line-pattern-file") { 698 | if (rawValue) { 699 | ctx.strokeStyle = ctx.createPattern(rawValue, "repeat"); 700 | shouldStroke = true; 701 | } 702 | } 703 | else if (key.indexOf("marker") === 0) { 704 | shouldMark = true; 705 | } 706 | else if (key === "point-file") { 707 | shouldPoint = true; 708 | } 709 | else if (key === "text-name") { 710 | shouldText = true; 711 | } 712 | } 713 | 714 | if (shouldFill || shouldStroke || shouldMark || shouldPoint || shouldText) { 715 | ctx.beginPath(); 716 | var shape = feature.geometry || feature; 717 | var coordinates = shape.coordinates; 718 | renderPath[shape.type].call(ctx, coordinates, scale); 719 | if (shouldFill) { 720 | ctx.globalAlpha = parseFloat(instanceStyle["polygon-opacity"]) || 1.0; 721 | var fillColor = instanceStyle["polygon-fill"]; 722 | var fillPattern = instanceStyle["polygon-pattern-file"] 723 | if (fillColor) { 724 | ctx.fillStyle = fillColor.toString(); 725 | ctx.fill(); 726 | } 727 | if (fillPattern) { 728 | ctx.fillStyle = ctx.createPattern(fillPattern, "repeat");; 729 | ctx.fill(); 730 | } 731 | } 732 | if (shouldStroke) { 733 | ctx.globalAlpha = parseFloat(instanceStyle["line-opacity"]) || 1.0; 734 | if (dashedStroke) { 735 | // since canvas doesn't yet have dashed line support, we have to draw a dashed path :( 736 | ctx.closePath(); 737 | ctx.beginPath(); 738 | renderDashedPath[shape.type].call(ctx, dashedStroke, coordinates); 739 | } 740 | ctx.stroke(); 741 | } 742 | ctx.closePath(); 743 | 744 | if (shouldMark) { 745 | if (instanceStyle["marker-file"]) { 746 | renderImage[shape.type].call(ctx, instanceStyle["marker-file"], coordinates); 747 | } 748 | else { 749 | // we only support the "ellipse" type 750 | // we only support circles, not ellipses right now :\ 751 | var radius = instanceStyle["marker-width"] || instanceStyle["marker-height"]; 752 | radius = (radius ? radius.value : 10) / 2; 753 | 754 | var shouldFillMarker = false; 755 | var shouldStrokeMarker = false; 756 | if (instanceStyle["marker-fill"]) { 757 | ctx.fillStyle = instanceStyle["marker-fill"].toString(); 758 | shouldFillMarker = true; 759 | } 760 | if (instanceStyle["marker-line-color"]) { 761 | ctx.strokeStyle = instanceStyle["marker-line-color"].toString(); 762 | shouldStrokeMarker = true; 763 | } 764 | if (instanceStyle["marker-line-width"]) { 765 | var lineWidth = instanceStyle["marker-line-width"].value; 766 | ctx.lineWidth = lineWidth; 767 | shouldStrokeMarker = !!lineWidth; 768 | } 769 | 770 | ctx.beginPath(); 771 | renderDot[shape.type].call(ctx, radius, coordinates); 772 | if (shouldFillMarker) { 773 | ctx.fill(); 774 | } 775 | if (shouldStrokeMarker) { 776 | ctx.stroke(); 777 | } 778 | ctx.closePath(); 779 | } 780 | } 781 | 782 | if (shouldPoint && instanceStyle["point-file"]) { 783 | renderImage[shape.type].call(ctx, instanceStyle["point-file"], coordinates); 784 | } 785 | 786 | if (shouldText) { 787 | var text = instanceStyle["text-name"]; 788 | if (text.is === "propertyLookup") { 789 | text = text.toString(feature); 790 | } 791 | 792 | if (text) { 793 | var textSize = (instanceStyle["text-size"] || "10").toString(); 794 | var textFace = (instanceStyle["text-face-name"] || "sans-serif").toString(); 795 | ctx.font = textSize + "px '" + textFace + "'"; 796 | 797 | var textInfo = { 798 | text: text 799 | }; 800 | 801 | // offsetting 802 | textInfo.offset = { 803 | x: instanceStyle["text-dx"] ? instanceStyle["text-dx"].value : 0, 804 | y: instanceStyle["text-dy"] ? instanceStyle["text-dy"].value : 0 805 | }; 806 | 807 | // vertical alignment 808 | var verticalAlign = instanceStyle["text-vertical-alignment"]; 809 | if (verticalAlign && verticalAlign.value !== "auto") { 810 | ctx.textBaseline = { 811 | top: "top", 812 | middle: "middle", 813 | bottom: "alphabetic" 814 | }[verticalAlign]; 815 | } 816 | else { 817 | ctx.textBaseline = textInfo.offset.y === 0 ? "middle" : (textInfo.offset.y < 0 ? "top" : "alphabetic"); 818 | } 819 | 820 | // horizontal alignment 821 | var textHorizontalAlignment = instanceStyle["text-horizontal-alignment"]; 822 | if (textHorizontalAlignment) { 823 | ctx.textAlign = textHorizontalAlignment.toString(); 824 | } 825 | else { 826 | // default to center 827 | ctx.textAlign = "center"; 828 | } 829 | textInfo.align = ctx.textAlign; 830 | 831 | // text-transform 832 | var textTransform = instanceStyle["text-transform"]; 833 | textTransform = textTransform && textTransform.toString(); 834 | if (textTransform === "uppercase") { 835 | textInfo.text = text.toUpperCase(); 836 | } 837 | if (textTransform === "lowercase") { 838 | textInfo.text = text.toLowerCase(); 839 | } 840 | if (textTransform === "capitalize") { 841 | textInfo.text = text.replace(/\b(\w)/g, function(match, character) { 842 | return character.toUpperCase(); 843 | }); 844 | } 845 | 846 | // text-placement 847 | var placement = instanceStyle["text-placement"]; 848 | textInfo.placement = placement ? placement.toString() : "point"; 849 | 850 | // TODO: text-placement-type (currently like "dummy") 851 | // TODO: text-align (this is alignment within the text *box*) 852 | // We don't have anything that generates a box right now, so we dont' support this 853 | 854 | if (instanceStyle["text-halo-fill"]) { 855 | ctx.strokeStyle = instanceStyle["text-halo-fill"].toString(); 856 | if (instanceStyle["text-halo-radius"]) { 857 | ctx.lineWidth = instanceStyle["text-halo-radius"].value * 2; 858 | } 859 | 860 | // definitely DON'T leave this set to miter :P 861 | ctx.lineJoin = "round"; 862 | textInfo.stroke = true; 863 | renderText[shape.type].call(ctx, textInfo, coordinates); 864 | } 865 | 866 | if (instanceStyle["text-fill"]) { 867 | ctx.fillStyle = instanceStyle["text-fill"].toString(); 868 | } 869 | 870 | textInfo.stroke = false; 871 | renderText[shape.type].call(ctx, textInfo, coordinates); 872 | } 873 | } 874 | } 875 | 876 | ctx.restore(); 877 | }; 878 | 879 | instanceOrder.forEach(function(instanceName) { 880 | if (instanceName !== "__default__") { 881 | renderInstance(instanceName); 882 | } 883 | }); 884 | 885 | if (collapsedStyle["__default__"]) { 886 | renderInstance("__default__"); 887 | } 888 | 889 | }); 890 | 891 | }); 892 | }); 893 | }; 894 | 895 | 896 | var cartoGridRenderer = function (ctx, scale, layers, styles, zoom) { 897 | var intColor = 1; // color zero is black/empty; so start with 1 898 | colorIndex = ['']; // make room for black/empty 899 | 900 | layers.forEach(function(layer, i) { 901 | var source = layer.source; 902 | var features = layer.features || layer; 903 | features.forEach(function(feature) { 904 | 905 | var collapsedStyle = {}; 906 | var attachmentOrder = []; 907 | styles.forEach(function(style) { 908 | if (cartoSelectorIsMatch(style, feature, source, zoom)) { 909 | if (!collapsedStyle[style.attachment]) { 910 | collapsedStyle[style.attachment] = {}; 911 | attachmentOrder.push(style.attachment); 912 | } 913 | 914 | var collapsedAttachmentStyle = collapsedStyle[style.attachment]; 915 | 916 | style.rules.forEach(function(rule) { 917 | if (!collapsedAttachmentStyle[rule.name]) { 918 | collapsedAttachmentStyle[rule.name] = rule.value.toString(); 919 | } 920 | }); 921 | } 922 | }); 923 | 924 | 925 | var renderAttachment = function(attachmentName) { 926 | var attachmentStyle = collapsedStyle[attachmentName]; 927 | 928 | ctx.save(); 929 | 930 | var shouldFill = false, 931 | shouldStroke = false; 932 | for (var key in attachmentStyle) { 933 | var value = attachmentStyle[key]; 934 | 935 | if (key === "background-color") { 936 | ctx.fillStyle = value; 937 | shouldFill = true; 938 | } 939 | else if (key === "background-image") { 940 | if (!(value instanceof Canvas.Image)) { 941 | var img = new Canvas.Image(); 942 | var imagePath = path.normalize(__dirname + "/../static/images/" + value); 943 | img.src = fs.readFileSync(imagePath); 944 | attachmentStyle[key] = img; 945 | } 946 | 947 | img = attachmentStyle[key]; 948 | ctx.fillStyle = ctx.createPattern(img, "repeat"); 949 | shouldFill = true; 950 | } 951 | else if (key === "line-width") { 952 | ctx.lineWidth = parseInt(value); 953 | } 954 | else if (key === "line-color") { 955 | ctx.strokeStyle = value; 956 | shouldStroke = true; 957 | } 958 | else if (key === "line-opacity") { 959 | // TODO: this needs to modify lineStyle 960 | // and lineStyle needs to watch for it 961 | } 962 | else if (key === "line-join") { 963 | ctx.lineJoin = value; 964 | } 965 | else if (key === "line-cap") { 966 | ctx.lineCap = value; 967 | } 968 | else if (key === "line-dasharray") { 969 | // TODO: dasharray support 970 | // console.log("Dasharray: ", value); 971 | // var dashArray = value.split(",").map(parseFloat); 972 | // console.log(" now: ", dashArray); 973 | // ctx.dash = dashArray; 974 | // ctx.lineDash = dashArray; 975 | } 976 | else if (key === "") { 977 | 978 | } 979 | 980 | } 981 | 982 | if (shouldFill || shouldStroke) { 983 | ctx.beginPath(); 984 | var shape = feature.geometry || feature; 985 | var coordinates = shape.coordinates; 986 | renderPath[shape.type].call(ctx, coordinates, scale); 987 | if (shouldFill) { 988 | ctx.fillStyle = '#' + d2h(intColor, 6); 989 | ctx.fill(); 990 | } 991 | if (shouldStroke) { 992 | ctx.strokeStyle = '#' + d2h(intColor, 6); 993 | ctx.stroke(); 994 | } 995 | ctx.closePath(); 996 | 997 | colorIndex.push(feature); // this should line up with our colors. 998 | intColor++; // Go on to the next color; 999 | } 1000 | 1001 | ctx.restore(); 1002 | }; 1003 | 1004 | attachmentOrder.forEach(function(attachmentName) { 1005 | if (attachmentName !== "__default__") { 1006 | renderAttachment(attachmentName); 1007 | } 1008 | }); 1009 | 1010 | if (collapsedStyle["__default__"]) { 1011 | renderAttachment("__default__"); 1012 | } 1013 | }); 1014 | }); 1015 | 1016 | return colorIndex; 1017 | }; 1018 | 1019 | // hex helper functions 1020 | function d2h(d, digits) { 1021 | d = d.toString(16); 1022 | while (d.length < digits) { 1023 | d = '0' + d; 1024 | } 1025 | 1026 | return d; 1027 | } 1028 | function h2d(h) { 1029 | return parseInt(h,16); 1030 | } 1031 | 1032 | var cartoSelectorIsMatch = function(definition, feature, source, zoom) { 1033 | // ZOOM 1034 | var supportedZooms = definition.zoom; 1035 | // 8388607 is all zooms 1036 | if (supportedZooms && supportedZooms !== 8388607) { 1037 | var minZoom, maxZoom; 1038 | for (var i = 0; i < MAX_ZOOM; i++) { 1039 | if (!minZoom && (supportedZooms & (1 << i))) { 1040 | minZoom = i; 1041 | } 1042 | if (minZoom && !(supportedZooms & (1 << i))) { 1043 | maxZoom = i - 1; 1044 | } 1045 | } 1046 | 1047 | if (minZoom > zoom || maxZoom < zoom) { 1048 | return false; 1049 | } 1050 | } 1051 | 1052 | // MAP BG 1053 | if (!feature) { 1054 | return !!__.find(definition.elements, function(element) { 1055 | return element.value === "Map"; 1056 | }); 1057 | } 1058 | 1059 | // SOURCES 1060 | var matches = true; 1061 | if (definition.elements.length) { 1062 | var hasSource = !!__.find(definition.elements, function(element) { 1063 | var elementName = element.value; 1064 | return elementName === "*" || elementName === ("#" + source) || elementName === ("." + source); 1065 | }); 1066 | if (!hasSource) { 1067 | return false; 1068 | } 1069 | } 1070 | 1071 | // OTHER FILTERS 1072 | if (definition.filters) { 1073 | for (var filterKey in definition.filters) { 1074 | var filter = definition.filters[filterKey]; 1075 | 1076 | if (filter.op === "=") { 1077 | if (feature.properties[filter.key] !== filter.val) { 1078 | return false; 1079 | } 1080 | } 1081 | } 1082 | } 1083 | return true; 1084 | }; 1085 | 1086 | exports.processStyles = function(styles, assetsPath) { 1087 | var processed = []; 1088 | var imageCache = {}; 1089 | 1090 | styles.forEach(function(cartoString, index) { 1091 | var env = { 1092 | filename: "Style" + index, 1093 | frames: [], 1094 | error: function(error) { 1095 | console.error("Carto parsing error: ", error); 1096 | } 1097 | }; 1098 | try { 1099 | var parsed = (carto.Parser(env)).parse(cartoString); 1100 | } 1101 | catch(ex) { 1102 | console.error("Error parsing Carto style #" + index + ": " + ex + "\n" + cartoString + "\n\n"); 1103 | return; 1104 | } 1105 | 1106 | var flattened = parsed.flatten([], [], env); 1107 | 1108 | var propertyMatcher = /^\[([^\]]+)\]$/; 1109 | flattened.forEach(function(ruleset) { 1110 | ruleset.rules.forEach(function(rule) { 1111 | // Carto@0.9.3 uses eval(), 0.9.4 uses ev() 1112 | rule.value = (rule.value.eval || rule.value.ev).call(rule.value, env); 1113 | 1114 | // preload URIs as images 1115 | if (rule.value.is === "uri") { 1116 | var result; 1117 | var stringValue = rule.value.toString(); 1118 | if (stringValue) { 1119 | result = imageCache[stringValue]; 1120 | if (!result) { 1121 | var imagePath = path.join(assetsPath, stringValue); 1122 | var image = new Canvas.Image(); 1123 | try { 1124 | image.src = fs.readFileSync(imagePath); 1125 | imageCache[stringValue] = image; 1126 | result = image; 1127 | } 1128 | catch(ex) {} 1129 | } 1130 | } 1131 | rule.value = result; 1132 | } 1133 | 1134 | if (rule.name === "text-name") { 1135 | var value = rule.value.toString(); 1136 | var propertyMatch = value.match(propertyMatcher); 1137 | if (propertyMatch) { 1138 | rule.value = { 1139 | is: 'propertyLookup', 1140 | property: propertyMatch[1], 1141 | toString: function(feature) { 1142 | return feature ? feature.properties[this.property] : this.property; 1143 | } 1144 | }; 1145 | } 1146 | else { 1147 | rule.value = value; 1148 | } 1149 | } 1150 | }); 1151 | 1152 | // deal with Carto@0.9.4 filters 1153 | if (ruleset.filters && ruleset.filters.filters) { 1154 | ruleset.filters = ruleset.filters.filters; 1155 | // this may not be the right thing to do in some complicated cases :\ 1156 | __.each(ruleset.filters, function(filter) { 1157 | if (filter.key.is === "field") { 1158 | filter.key = filter.key.value; 1159 | } 1160 | filter.val = filter.val.toString(); 1161 | }); 1162 | } 1163 | }); 1164 | 1165 | processed = processed.concat(flattened); 1166 | }, this); 1167 | 1168 | // Sort rules by specificity 1169 | // (copied from carto.Parser - it's private, so we can't just reach in and use it) 1170 | processed.sort(function(a, b) { 1171 | var as = a.specificity; 1172 | var bs = b.specificity; 1173 | 1174 | if (as[0] != bs[0]) return bs[0] - as[0]; 1175 | if (as[1] != bs[1]) return bs[1] - as[1]; 1176 | if (as[2] != bs[2]) return bs[2] - as[2]; 1177 | return bs[3] - as[3]; 1178 | }); 1179 | 1180 | return processed; 1181 | }; 1182 | -------------------------------------------------------------------------------- /lib/projector.js: -------------------------------------------------------------------------------- 1 | // TODO this should support passing in projection strings in many formats, including preconstructed Proj4 objects 2 | 3 | var Proj4js = require('proj4js'); 4 | require('proj4js-defs')(Proj4js); 5 | var __ = require('lodash'); 6 | 7 | var A = 6378137, 8 | MAXEXTENT = 20037508.34, 9 | ORIGIN_SHIFT = Math.PI * 6378137, 10 | D2R = Math.PI / 180, 11 | R2D = 180 / Math.PI; //20037508.342789244 12 | 13 | // Cache for for storing and reusing Proj4 instances 14 | var projectorCache = {}; 15 | 16 | // Ensure that you have a Proj4 object, pulling from the cache if necessary 17 | var getProj4 = function(projection) { 18 | if (projection instanceof Proj4js.Proj) { 19 | return projection; 20 | } 21 | else if (projection in projectorCache) { 22 | return projectorCache[projection]; 23 | } 24 | else { 25 | return projectorCache[projection] = new Proj4js.Proj(projection); 26 | } 27 | }; 28 | 29 | //projection defs: we should add more here 30 | 31 | // Credit for the math: http://www.maptiler.org/google-maps-coordinates-tile-bounds-projection/ 32 | // TODO: just use https://github.com/mapbox/node-sphericalmercator/blob/master/sphericalmercator.js 33 | var util = { 34 | cleanProjString: function(text) { 35 | if (typeof text == "number") { 36 | return "EPSG:"+text; 37 | } else if (text.indexOf("EPSG:") > -1){ 38 | return text; 39 | } else if (text.indexOf("+proj") > -1) { 40 | // proj4 string 41 | Proj4js.defs["NODETILES:9999"] = text; 42 | return "NODETILES:9999"; 43 | } else { 44 | console.warn("Invalid projection string"); 45 | return "EPSG:4326" 46 | } 47 | }, 48 | pixelsToMeters: function(x, y, zoom, tileSize) { 49 | var mx, my; 50 | var tileSize = tileSize || 256; 51 | // meters per pixel at zoom 0 52 | var initialResolution = 2 * Math.PI * 6378137 / tileSize; 53 | //Resolution (meters/pixel) for given zoom level (measured at Equator)" 54 | var res = initialResolution / Math.pow(2,zoom); 55 | // return (2 * math.pi * 6378137) / (self.tileSize * 2**zoom) 56 | mx = x * res - ORIGIN_SHIFT; 57 | my = y * res - ORIGIN_SHIFT; 58 | return [mx, my]; 59 | }, 60 | 61 | // Thanks to https://github.com/mapbox/node-sphericalmercator/blob/master/sphericalmercator.js 62 | metersToLatLon: function(c) { 63 | return [ 64 | (c[0] * R2D / A), 65 | ((Math.PI*0.5) - 2.0 * Math.atan(Math.exp(-c[1] / A))) * R2D 66 | ]; 67 | }, 68 | latLonToMeters: function(c) { 69 | var xy = [ 70 | A * c[0] * D2R, 71 | A * Math.log(Math.tan((Math.PI*0.25) + (0.5 * c[1] * D2R))) 72 | ]; 73 | // if xy value is beyond maxextent (e.g. poles), return maxextent. 74 | (xy[0] > MAXEXTENT) && (xy[0] = MAXEXTENT); 75 | (xy[0] < -MAXEXTENT) && (xy[0] = -MAXEXTENT); 76 | (xy[1] > MAXEXTENT) && (xy[1] = MAXEXTENT); 77 | (xy[1] < -MAXEXTENT) && (xy[1] = -MAXEXTENT); 78 | return xy; 79 | }, 80 | tileToMeters: function(x, y, zoom, tileSize){ 81 | var tileSize = tileSize || 256; 82 | y = (Math.pow(2,zoom) - 1) - y; // TMS to Google tile scheme 83 | var min = util.pixelsToMeters(x*tileSize, y*tileSize, zoom); 84 | var max = util.pixelsToMeters((x+1)*tileSize, (y+1)*tileSize, zoom); 85 | return [min[0], min[1], max[0], max[1]]; 86 | } 87 | } 88 | var project = { 89 | 90 | 'FeatureCollection': function(inProjection, outProjection, fc) { 91 | var from = getProj4(inProjection); 92 | var to = getProj4(outProjection); 93 | 94 | var _fc = __.clone(fc); 95 | //console.log(_fc.features[0].geometry.coordinates[0]); 96 | _fc.features = _fc.features.map(project.Feature.bind(null, from, to)); 97 | //console.log(_fc.features[0].geometry.coordinates[0]); 98 | return _fc; 99 | }, 100 | 'Feature': function(inProjection, outProjection, f) { 101 | var _f = __.clone(f); 102 | _f.geometry = __.clone(f.geometry); 103 | _f.geometry.coordinates = project[f.geometry.type](inProjection, outProjection, _f.geometry.coordinates); 104 | return _f; 105 | }, 106 | 'MultiPolygon': function(inProjection, outProjection, mp) { 107 | return mp.map(project.Polygon.bind(null, inProjection, outProjection)); 108 | }, 109 | 'Polygon': function(inProjection, outProjection, p) { 110 | return p.map(project.LineString.bind(null, inProjection, outProjection)); 111 | }, 112 | 'MultiLineString': function(inProjection, outProjection, ml) { 113 | return ml.map(project.LineString.bind(null, inProjection, outProjection)); 114 | }, 115 | 'LineString': function(inProjection, outProjection, l) { 116 | return l.map(project.Point.bind(null, inProjection, outProjection)); 117 | }, 118 | 'MultiPoint': function(inProjection, outProjection, mp) { 119 | return mp.map(project.Point.bind(null, inProjection, outProjection)); 120 | }, 121 | 'Point': function(inProjection, outProjection, c) { 122 | if (inProjection && outProjection) { 123 | var inProjectionCode = inProjection instanceof Proj4js.Proj ? 124 | 'EPSG:' + inProjection.srsProjNumber : inProjection; 125 | var outProjectionCode = outProjection instanceof Proj4js.Proj ? 126 | 'EPSG:' + outProjection.srsProjNumber : outProjection; 127 | 128 | if (inProjectionCode == 'EPSG:4326' && outProjectionCode == 'EPSG:900913') { 129 | return util.latLonToMeters(c); 130 | } 131 | else if (inProjectionCode == 'EPSG:900913' && outProjectionCode == 'EPSG:4326') { 132 | return util.metersToLatLon(c); 133 | } 134 | 135 | var from = getProj4(inProjection); 136 | var to = getProj4(outProjection); 137 | var point = new Proj4js.Point(c); 138 | Proj4js.transform(from, to, point); 139 | return [point.x, point.y]; 140 | } 141 | return c; 142 | } 143 | }; 144 | 145 | // TODO: cleanup interface 146 | module.exports.util = util; 147 | module.exports.project = project; 148 | 149 | 150 | // {}; 151 | // this is sexy but doesn't work 152 | /*Object.keys(project).forEach(function(featureType) { 153 | exports.project[featureType] = function(inProjection, outProjection, feature) { 154 | var from = inProjection && new Proj4js.Proj(inProjection), 155 | to = outProjection && new Proj4js.Proj(outProjection); 156 | 157 | return project[featureType](null, null, feature); 158 | }; 159 | }); 160 | */ 161 | -------------------------------------------------------------------------------- /lib/renderer.js: -------------------------------------------------------------------------------- 1 | var Canvas = require('canvas'); 2 | var UTFGrid = require('./utfgrid'); 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var __ = require('lodash'); 6 | 7 | // FIXME: this *really* shouldn't be here 8 | var bgColor = '#ddddff'; 9 | 10 | // Handles canvas and context management, then passes things off to an actual rendering routine 11 | // TODO: support arbitrary canvas size (or at least @2x size) 12 | // TODO: support arbitrary render routines 13 | var renderImage = exports.renderImage = function(minX, minY, maxX, maxY, width, height, layers, styles, callback) { 14 | var canvas = new Canvas(width, height), 15 | ctx = canvas.getContext('2d'), 16 | // we're using the same ratio for width and height, so the actual maxY may not match specified maxY... 17 | pxPtRatio = width / (maxX - minX); 18 | 19 | // var img = new Canvas.Image(); 20 | // img.src = fs.readFileSync("/Users/rbrackett/Dev/cfa/nodetiles/static/images/furley_bg.png"); 21 | // ctx.drawImage(img, 0, 0, img.width, img.height); 22 | 23 | 24 | 25 | 26 | // 27 | transform = function(p) { 28 | var point = []; 29 | point[0] = (p[0] - minX) * pxPtRatio; 30 | point[1] = ((p[1] - minY) * -pxPtRatio) + height; 31 | return point; 32 | }; 33 | 34 | // var scale = Math.pow(2, zoom); 35 | // ctx.scale(scale, scale); 36 | // ctx.translate(-x * 256 / scale, -y * 256 / scale); 37 | 38 | // renderer is provided somehow (but we'll have a simple default) 39 | imageRenderer(ctx, pxPtRatio, layers, styles); 40 | 41 | // Print coordinates on tiles 42 | // ctx.textAlign = 'center'; 43 | // ctx.font = "10px sans-serif"; 44 | // ctx.fillStyle = "#000"; 45 | // ctx.strokeStyle = "#fff"; 46 | // ctx.lineWidth = 3; 47 | // ctx.strokeText("Coords: "+minX+", "+minY,width/2,height/2+5); 48 | // ctx.strokeText("Coords: "+maxX+", "+maxY,width/2,height/2+20); 49 | // ctx.fillText("Coords: "+minX+", "+minY,width/2,height/2+5); 50 | // ctx.fillText("Coords: "+maxX+", "+maxY,width/2,height/2+20); 51 | 52 | callback(null, canvas); 53 | }; 54 | 55 | var renderGrid = exports.renderGrid = function(minX, minY, maxX, maxY, width, height, layers, styles, featureImage, callback) { 56 | var canvas = new Canvas(width, width), 57 | ctx = canvas.getContext('2d'), 58 | // we're using the same ratio for width and height, so the actual maxY may not match specified maxY... 59 | pxPtRatio = width / (maxX - minX), 60 | gridSize = width; 61 | 62 | transform = function(p) { 63 | var point = []; 64 | point[0] = (p[0] - minX) * pxPtRatio; 65 | point[1] = ((p[1] - minY) * -pxPtRatio) + height; 66 | return point; 67 | }; 68 | 69 | ctx.antialias = 'none'; 70 | ctx.fillStyle = '#000000'; // Paint it black 71 | ctx.fillRect(0, 0, gridSize, gridSize); 72 | 73 | // ctx.scale(pxPtRatio, pxPtRatio); 74 | // ctx.translate(-minX, -minY); 75 | 76 | 77 | // renderer is provided somehow (but we'll have a simple default) 78 | var colorIndex = gridRenderer(ctx, pxPtRatio, layers, styles); 79 | 80 | // return the image we just rendered instead of the actual grid (for debugging) 81 | if (featureImage) { 82 | callback(undefined, canvas); 83 | } 84 | else { 85 | var pixels = ctx.getImageData(0, 0, gridSize, gridSize).data; // array of all pixels 86 | var utfgrid = (new UTFGrid(gridSize, function (point) { 87 | // Use our raster (ctx) and colorIndex to lookup the corresponding feature 88 | 89 | //look up the the rgba values for the pixel at x,y 90 | // scan rows and columns; each pixel is 4 separate values (R,G,B,A) in the array 91 | var startPixel = (gridSize * point.y + point.x) * 4; 92 | 93 | // convert those rgba elements to hex then an integer 94 | var intColor = h2d(d2h(pixels[startPixel], 2) + d2h(pixels[startPixel + 1], 2) + d2h(pixels[startPixel + 2], 2)); 95 | 96 | return colorIndex[intColor]; // returns the feature that's referenced in colorIndex. 97 | })).encodeAsObject(); 98 | 99 | for(var featureId in utfgrid.data) { 100 | utfgrid.data[featureId] = utfgrid.data[featureId].properties; 101 | } 102 | 103 | callback(undefined, utfgrid); 104 | } 105 | }; 106 | 107 | // NB:- these functions are called using 'this' as our canvas context 108 | // it's not clear to me whether this architecture is right but it's neat ATM. 109 | var transform = null; 110 | var renderPath = { 111 | 'MultiPolygon': function(mp) { 112 | mp.forEach(renderPath.Polygon, this); 113 | }, 114 | /** 115 | * NOTE: RENDERING POLYGONS WITH HOLES 116 | * Canvas appears to use the "ESRI Polygon" convention: 117 | * 118 | * The ESRI polygon is an array of rings and an inner ring, or hole, 119 | * is specified by ordering the points counter-clockwise. 120 | * 121 | * Alternative, the GeoJSON polygon is an array of rings, but the first 122 | * is always the outer ring and the following ones are inner rings. 123 | * 124 | * Creating polygons with holes that follow the ESRI Polygon convention 125 | * magically seems to work with Node-Canvas/Cairo. Weird. 126 | */ 127 | 'Polygon': function(p) { 128 | p.forEach(renderPath.LineString, this); 129 | }, 130 | 'MultiLineString': function(ml) { 131 | ml.forEach(renderPath.LineString, this); 132 | }, 133 | 'LineString': function(l) { 134 | var start = l[0]; 135 | if (transform) { 136 | start = transform(start); 137 | } 138 | this.moveTo(start[0], start[1]); 139 | l.slice(1).forEach(function(c){ 140 | if (transform) { 141 | c = transform(c); 142 | } 143 | this.lineTo(c[0], c[1]); 144 | }, this); 145 | }, 146 | 'MultiPoint': function(p, scale) { 147 | // Can't use forEach here because we need to pass scale along 148 | for (var i = 0, len = p.length; i < len; i++) { 149 | renderPath.Point.call(this, p[i], scale); 150 | } 151 | }, 152 | 'Point': function(p, scale) { 153 | if (transform) { 154 | p = transform(p); 155 | this.arc(p[0], p[1], 8, 0, Math.PI * 2, true); 156 | } 157 | else { 158 | this.arc(p[0], p[1], 8 / scale, 0, Math.PI * 2, true); 159 | } 160 | } 161 | }; 162 | 163 | // Do the actual render. It should be possible for the caller to provide this 164 | var imageRenderer = function (ctx, scale, layers, styles) { 165 | // background first 166 | styles.forEach(function(style) { 167 | if (selectorIsMatch(style.selector)) { 168 | if (style.properties["background-color"]) { 169 | ctx.fillStyle = style.properties["background-color"]; 170 | ctx.fillRect(0,0,ctx.canvas.width,ctx.canvas.height); 171 | } 172 | if (style.properties["background-image"]) { 173 | // cut off the "url(...)" 174 | if (typeof style.properties["background-image"] === "string") { 175 | var content = style.properties["background-image"].slice(4, -1); 176 | var img = new Canvas.Image(); 177 | var imagePath = path.normalize(__dirname + "/../static/images/" + content); 178 | img.src = fs.readFileSync(imagePath); 179 | style.properties["background-image"] = img; 180 | } 181 | 182 | img = style.properties["background-image"]; 183 | // ctx.drawImage(img, 0, 0, img.width, img.height); 184 | 185 | ctx.fillStyle = ctx.createPattern(img, "repeat"); 186 | ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); 187 | } 188 | } 189 | }); 190 | 191 | layers.forEach(function(layer, i) { 192 | var source = layer.source; 193 | var features = layer.features || layer; 194 | features.forEach(function(feature) { 195 | styles.forEach(function(style) { 196 | if (selectorIsMatch(style.selector, feature, source)) { 197 | style = style.properties; 198 | 199 | ctx.fillStyle = style.fillStyle || style['background-color'] || ''; 200 | ctx.strokeStyle = style.strokeStyle || style['line-color'] || ''; 201 | var lineWidth = style.lineWidth || style['line-width'] || 1.0; 202 | ctx.lineWidth = lineWidth;// / scale; 203 | 204 | ctx.beginPath(); 205 | var shape = feature.geometry || feature; 206 | var coordinates = shape.coordinates; 207 | renderPath[shape.type].call(ctx, coordinates, scale); 208 | if (style.fillStyle) { 209 | ctx.fill(); 210 | } 211 | if (style.strokeStyle || style['line-color']) { 212 | ctx.stroke(); 213 | } 214 | ctx.closePath(); 215 | } 216 | }); 217 | }); 218 | }); 219 | 220 | }; 221 | 222 | 223 | var gridRenderer = function (ctx, scale, layers, styles) { 224 | var intColor = 1; // color zero is black/empty; so start with 1 225 | colorIndex = ['']; // make room for black/empty 226 | 227 | 228 | layers.forEach(function(layer, i) { 229 | var source = layer.source; 230 | var features = layer.features || layer; 231 | features.forEach(function(feature) { 232 | styles.forEach(function(style) { 233 | if (selectorIsMatch(style.selector, feature, source) && style.selector.interactive) { 234 | style = style.properties; 235 | 236 | ctx.fillStyle = style.fillStyle ? '#' + d2h(intColor, 6) : ''; // only fill in if we have a style defined 237 | ctx.strokeStyle = style.strokeStyle ? '#' + d2h(intColor, 6) : ''; 238 | 239 | // ctx.fillStyle = style.fillStyle || style['background-color'] || ''; 240 | // ctx.strokeStyle = style.strokeStyle || style['line-color'] || ''; 241 | var lineWidth = style.lineWidth || style['line-width'] || 1.0; 242 | ctx.lineWidth = lineWidth;// / scale; 243 | 244 | ctx.beginPath(); 245 | var shape = feature.geometry || feature; 246 | var coordinates = shape.coordinates; 247 | // var coordinates = feature.geometry ? feature.geometry.coordinates : feature.coordinates; 248 | renderPath[shape.type].call(ctx, coordinates, scale); 249 | if (style.fillStyle) { 250 | ctx.fill(); 251 | } 252 | if (style.strokeStyle || style['line-color']) { 253 | ctx.stroke(); 254 | } 255 | ctx.closePath(); 256 | 257 | colorIndex.push(feature); // this should like up with our colors. 258 | intColor++; // Go on to the next color; 259 | } 260 | }); 261 | }); 262 | }); 263 | 264 | return colorIndex; 265 | }; 266 | 267 | // hex helper functions 268 | function d2h(d, digits) { 269 | d = d.toString(16); 270 | while (d.length < digits) { 271 | d = '0' + d; 272 | } 273 | 274 | return d; 275 | } 276 | function h2d(h) { 277 | return parseInt(h,16); 278 | } 279 | 280 | var selectorIsMatch = function(selector, feature, source) { 281 | var matches = true; 282 | for (var key in selector) { 283 | if (key === "source") { 284 | matches = matches && selector[key] === source; 285 | } 286 | else if (key === "zoom") { 287 | // matches = matches && true; 288 | } 289 | else if (key === "ruleName") { 290 | // noop 291 | } 292 | else if (key === "interactive") { 293 | // noop 294 | } 295 | else if (key === "background") { 296 | var valid = feature ? !selector[key] : selector[key]; 297 | matches = matches && valid; 298 | } 299 | else { 300 | matches = matches && feature.properties[key] === selector[key]; 301 | } 302 | if (!matches) { 303 | return false; 304 | } 305 | } 306 | return matches; 307 | }; 308 | 309 | var processStyles = exports.processStyles = function(styles) { 310 | var processed = []; 311 | 312 | styles.forEach(function(style) { 313 | var existing = __.find(processed, function(processedStyle) { 314 | // return __.isEqual(processedStyle.selector, style.selector); 315 | return deepEqual(processedStyle.selector, style.selector); 316 | }); 317 | 318 | if (existing) { 319 | for (var key in style.properties) { 320 | existing.properties[key] = style.properties[key]; 321 | } 322 | } 323 | else { 324 | processed.push(style); 325 | } 326 | }); 327 | 328 | return processed; 329 | }; 330 | 331 | var deepEqual = function(a, b) { 332 | try { 333 | var aKeys = Object.keys(a), 334 | bKeys = Object.keys(b); 335 | } 336 | catch(ex) { 337 | return a === b; 338 | } 339 | 340 | if (aKeys.length === bKeys.length) { 341 | var equal = true; 342 | for (var i = aKeys.length - 1; i >= 0; i--) { 343 | var aKey = aKeys[i], 344 | bKey = bKeys[i]; 345 | if (aKey === bKey) { 346 | var aVal = a[aKey], 347 | bVal = b[bKey]; 348 | if (aVal instanceof Date) { 349 | equal = equal && (bVal instanceof Date) && aVal.valueOf() === bVal.valueOf(); 350 | } 351 | else if (aVal instanceof RegExp) { 352 | equal = equal && (bVal instanceof RegExp) && (aVal.toString() === bVal.toString()); 353 | } 354 | else if (typeof aVal !== "object") { 355 | equal = equal && aVal === bVal; 356 | } 357 | else { 358 | equal = equal && deepEqual(aVal, bVal); 359 | } 360 | } 361 | else { 362 | return false; 363 | } 364 | } 365 | return equal; 366 | } 367 | 368 | return false; 369 | }; 370 | -------------------------------------------------------------------------------- /lib/routes.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | Map = require('./projector'), 3 | Projector = require('./projector'); 4 | 5 | /** 6 | * Map tile routing 7 | * :zoom/:col/:row.png routing 8 | */ 9 | module.exports.tilePng = function tilePng(options){ 10 | var options = options || {}, 11 | map = options.map; 12 | 13 | if (!options.map) { 14 | throw new Error("You must set options.map equal to your map"); 15 | } 16 | 17 | return function tilePng(req, res, next){ 18 | var tileCoordinate, bounds; 19 | // verify arguments 20 | tileCoordinate = req.path.match(/(\d+)\/(\d+)\/(\d+)\.png$/); 21 | if (!tileCoordinate) { 22 | return next(); 23 | } 24 | // slice the regexp down to usable size 25 | tileCoordinate = tileCoordinate.slice(1,4).map(Number); 26 | 27 | // set the bounds and render 28 | bounds = Projector.util.tileToMeters(tileCoordinate[1], tileCoordinate[2], tileCoordinate[0]); 29 | map.render({ 30 | bounds: {minX: bounds[0], minY: bounds[1], maxX: bounds[2], maxY: bounds[3]}, 31 | width: 256, 32 | height: 256, 33 | zoom: tileCoordinate[0], 34 | callback: function(err, canvas) { 35 | // TODO: catche the error 36 | var stream = canvas.createPNGStream(); 37 | stream.pipe(res); 38 | } 39 | }); 40 | }; 41 | }; 42 | 43 | /** 44 | * UTFGrid routing 45 | * :zoom/:col/:row.jsonp routing 46 | */ 47 | module.exports.utfGrid = function utfGrid(options){ 48 | var options = options || {}, 49 | map = options.map, 50 | format; 51 | 52 | if (!options.map) { 53 | throw new Error("You must set options.map equal to your map"); 54 | } 55 | 56 | 57 | return function tilePng(req, res, next){ 58 | var tileCoordinate, format, bounds; 59 | 60 | // verify arguments (don't forget jsonp!) 61 | tileCoordinate = req.path.match(/(\d+)\/(\d+)\/(\d+)\.(png|json|jsonp)$/); 62 | if (!tileCoordinate) { 63 | return next(); 64 | } 65 | 66 | // slice the regexp down to usable size 67 | console.log(tileCoordinate[4]); 68 | format = tileCoordinate[4]; 69 | tileCoordinate = tileCoordinate.slice(1,4).map(Number); 70 | 71 | // Show the rasterized utfgrid for debugging 72 | respondWithImage = format === 'png'; 73 | if (respondWithImage) { 74 | renderHandler = function(err, canvas) { 75 | var stream = canvas.createPNGStream(); 76 | stream.pipe(res); 77 | }; 78 | } 79 | else { 80 | renderHandler = function(err, grid) { 81 | res.jsonp(grid); 82 | }; 83 | } 84 | bounds = Projector.util.tileToMeters(tileCoordinate[1], tileCoordinate[2], tileCoordinate[0], 64); 85 | map.renderGrid({ 86 | bounds: {minX: bounds[0], minY: bounds[1], maxX: bounds[2], maxY: bounds[3]}, 87 | width: 64, 88 | height: 64, 89 | zoom: tileCoordinate[0], 90 | drawImage: respondWithImage, 91 | callback: renderHandler 92 | }); 93 | }; 94 | }; 95 | 96 | module.exports.tileJson = function tileJson(options) { 97 | var options = options || {}, 98 | path = options.path; 99 | 100 | if (!options.path) { 101 | throw new Error("You must set options.path to point to your tile.json file"); 102 | } 103 | 104 | return function tileJson(req, res, next){ 105 | fs.readFile(path, 'utf8', function(err, file){ 106 | if (err) return next(err); 107 | var tileJson; 108 | 109 | // don't let JSON.parse barf all over everything 110 | try { 111 | tileJson = JSON.parse(file); 112 | } 113 | catch(err) { 114 | return next(err); 115 | } 116 | 117 | return res.jsonp(tileJson); 118 | }); 119 | } 120 | }; -------------------------------------------------------------------------------- /lib/utfgrid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a UTFGrid. 3 | * 4 | * Usage: 5 | * var gridJSON = UTFGrid.generate(64, function(coord) { 6 | * // lookup the color at point coord in image 7 | * // lookup feature associated with color 8 | * // return feature 9 | * }); 10 | * 11 | * eg. the delegate function should return a feature 12 | * when passed a {x: x, y: y} object 13 | * 14 | * Credit: Rob Brackett (mr0grog) 15 | */ 16 | 17 | var UTFGrid = function (size, delegate) { 18 | this.size = size; 19 | this.delegate = delegate; 20 | this._features = []; 21 | this._codePoints = {}; 22 | this._lastCodePoint = 32; 23 | }; 24 | 25 | UTFGrid.generate = function(size, delegate) { 26 | return (new UTFGrid(size, delegate)).encode(); 27 | }; 28 | 29 | UTFGrid.prototype = { 30 | constructor: UTFGrid, 31 | 32 | encode: function() { 33 | return JSON.stringify(this.encodeAsObject()); 34 | }, 35 | 36 | encodeAsObject: function() { 37 | var grid = { 38 | grid: [], 39 | keys: [""], 40 | data: {} 41 | }; 42 | 43 | for (var y = 0; y < this.size; y++) { 44 | var gridRow = ""; 45 | for (var x = 0; x < this.size; x++) { 46 | var feature = this.delegate({x: x, y: y}); 47 | if (feature) { 48 | var id = this._features.indexOf(feature); 49 | if (id === -1) { 50 | id = this._features.push(feature) - 1; 51 | 52 | grid.keys.push(id.toString(10)); 53 | grid.data[id] = feature; 54 | } 55 | 56 | gridRow += String.fromCharCode(this._codePointForId(id)); 57 | } 58 | else { 59 | gridRow += " "; 60 | } 61 | } 62 | grid.grid.push(gridRow); 63 | } 64 | 65 | return grid; 66 | }, 67 | 68 | _codePointForId: function(id) { 69 | if (!this._codePoints[id]) { 70 | // Skip '"' and '\' 71 | var codePoint = ++this._lastCodePoint; 72 | if (codePoint === 34 || codePoint === 92) { 73 | codePoint += 1; 74 | this._lastCodePoint += 1; 75 | } 76 | 77 | this._codePoints[id] = codePoint; 78 | } 79 | return this._codePoints[id]; 80 | } 81 | }; 82 | 83 | module.exports = UTFGrid; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodetiles-core", 3 | "version": "0.0.5", 4 | "description": "Joyful map rendering with Node.js.", 5 | "contributors": [ 6 | { 7 | "name": "Rob Bracket", 8 | "email": "rob@codeforamerica.org" 9 | }, 10 | { 11 | "name": "Ben Sheldon", 12 | "email": "ben@codeforamerica.org" 13 | }, 14 | { 15 | "name": "Alex Yule", 16 | "email": "alexy@codeforamerica.org" 17 | } 18 | ], 19 | "license": "BSD", 20 | "main": "./index", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/nodetiles/nodetiles-core.git" 24 | }, 25 | "engines": { 26 | "node": ">0.8.x", 27 | "npm": ">1.1.x" 28 | }, 29 | "dependencies": { 30 | "async": "~0.2", 31 | "lodash": "~1.2", 32 | "canvas": ">=1.0 <2.0", 33 | "proj4js": "~0.3.0", 34 | "proj4js-defs": ">=0.0.1", 35 | "carto": "~0.9.5", 36 | "shp": "https://github.com/yuletide/node-shp/tarball/master" 37 | }, 38 | "devDependencies": { 39 | "mocha": "~1.17.1", 40 | "chai": "~1.9.0", 41 | "sinon": "~1.8.0", 42 | "istanbul": "~0.2.4", 43 | "coveralls": "~2.7.1", 44 | "imagediff": "~1.0.8" 45 | }, 46 | "scripts": { 47 | "pretest": "npm ls --depth=Infinity > /dev/null", 48 | "test": "mocha" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodetiles/nodetiles-core/117853de6935081f7ebefb19c766ab6c1bded244/screenshot.png -------------------------------------------------------------------------------- /test/Map.spec.js: -------------------------------------------------------------------------------- 1 | var expect = require("chai").expect; 2 | var sinon = require("sinon"); 3 | var Map = require(__dirname + '/../index').Map; 4 | 5 | describe("Map", function() { 6 | 7 | describe("bounds buffering", function() { 8 | 9 | it("should default to 25%", function() { 10 | var data = sinon.stub().returns([]); 11 | var map = new Map(); 12 | map.addData(data); 13 | map.addStyle("Map { background-color: #999; }"); 14 | map.render({ 15 | bounds: {minX: 0, minY: 0, maxX: 10, maxY: 10}, 16 | width: 256, 17 | height: 256, 18 | zoom: 10 19 | }); 20 | expect(data.calledWith(-2.5, -2.5, 12.5, 12.5)).to.be.true; 21 | }); 22 | 23 | it("should accept a custom value per map", function() { 24 | var data = sinon.stub().returns([]); 25 | var map = new Map({ 26 | boundsBuffer: 0.5 27 | }); 28 | map.addData(data); 29 | map.addStyle("Map { background-color: #999; }"); 30 | map.render({ 31 | bounds: {minX: 0, minY: 0, maxX: 10, maxY: 10}, 32 | width: 256, 33 | height: 256, 34 | zoom: 10 35 | }); 36 | expect(data.calledWith(-5, -5, 15, 15)).to.be.true; 37 | }); 38 | 39 | it("should accept a custom function per map", function() { 40 | var data = sinon.stub().returns([]); 41 | var map = new Map({ 42 | boundsBuffer: function() { 43 | return {minX: -1, minY: 1, maxX: 12, maxY: 9}; 44 | } 45 | }); 46 | map.addData(data); 47 | map.addStyle("Map { background-color: #999; }"); 48 | map.render({ 49 | bounds: {minX: 0, minY: 0, maxX: 10, maxY: 10}, 50 | width: 256, 51 | height: 256, 52 | zoom: 10 53 | }); 54 | expect(data.calledWith(-1, 1, 12, 9)).to.be.true; 55 | }); 56 | 57 | it("should accept a custom value per render", function() { 58 | var data = sinon.stub().returns([]); 59 | var map = new Map({ 60 | boundsBuffer: 0.5 61 | }); 62 | map.addData(data); 63 | map.addStyle("Map { background-color: #999; }"); 64 | map.render({ 65 | bounds: {minX: 0, minY: 0, maxX: 10, maxY: 10}, 66 | width: 256, 67 | height: 256, 68 | zoom: 10, 69 | boundsBuffer: 0.1 70 | }); 71 | expect(data.calledWith(-1, -1, 11, 11)).to.be.true; 72 | }); 73 | 74 | it("should accept a custom function per render", function() { 75 | var data = sinon.stub().returns([]); 76 | var map = new Map({ 77 | boundsBuffer: 0.5 78 | }); 79 | map.addData(data); 80 | map.addStyle("Map { background-color: #999; }"); 81 | map.render({ 82 | bounds: {minX: 0, minY: 0, maxX: 10, maxY: 10}, 83 | width: 256, 84 | height: 256, 85 | zoom: 10, 86 | boundsBuffer: function() { 87 | return {minX: -1, minY: 1, maxX: 12, maxY: 9}; 88 | } 89 | }); 90 | expect(data.calledWith(-1, 1, 12, 9)).to.be.true; 91 | }); 92 | 93 | it("should be able to be 0", function() { 94 | var data = sinon.stub().returns([]); 95 | var map = new Map({ 96 | boundsBuffer: 0 97 | }); 98 | map.addData(data); 99 | map.addStyle("Map { background-color: #999; }"); 100 | map.render({ 101 | bounds: {minX: 0, minY: 0, maxX: 10, maxY: 10}, 102 | width: 256, 103 | height: 256, 104 | zoom: 10 105 | }); 106 | expect(data.calledWith(0, 0, 10, 10)).to.be.true; 107 | }); 108 | 109 | }); 110 | 111 | }); 112 | -------------------------------------------------------------------------------- /test/cartoRenderer.spec.js: -------------------------------------------------------------------------------- 1 | // test tools 2 | var expect = require('chai').expect; 3 | var sinon = require('sinon'); 4 | var imagediff = require('imagediff'); 5 | var fs = require('fs'); 6 | var Canvas = require('canvas'); 7 | // lib 8 | var nodetiles = require('../index'); 9 | var projector = nodetiles.projector; 10 | 11 | describe('cartoRenderer', function() { 12 | 13 | simpleTest('markers'); 14 | 15 | /** 16 | * Runs a simple test of the renderer against supplied data, styles, and 17 | * an expected rendered result. Given a name, it will read: 18 | * - cartoRenderer/name.json (data) 19 | * - cartoRenderer/name.mss (carto stylesheet) 20 | * - cartoRenderer/name.png (expected result) 21 | */ 22 | function simpleTest(name) { 23 | it(name, function(done) { 24 | var base = __dirname + '/cartoRenderer/' + name; 25 | var style = fs.readFileSync(base + '.mss', 'utf-8'); 26 | var data = JSON.parse(fs.readFileSync(base + '.json')); 27 | var bounds = data.bounds; 28 | if (data.tile) { 29 | var extents = projector.util.tileToMeters(data.tile.x, data.tile.y, data.tile.z); 30 | bounds = { 31 | minX: extents[0], 32 | minY: extents[1], 33 | maxX: extents[2], 34 | maxY: extents[3] 35 | }; 36 | } 37 | 38 | var map = new nodetiles.Map(); 39 | map.addStyle(style); 40 | map.addData({ 41 | sourceName: "data", // for the stylesheet to use 42 | getShapes: function(minX, minY, maxX, maxY, mapProjection) { 43 | return projector.project[data.type]("EPSG:4326", mapProjection, data); 44 | } 45 | }); 46 | 47 | map.render({ 48 | bounds: bounds, 49 | width: 256, 50 | height: 256, 51 | zoom: data.tile ? data.tile.z : 1, 52 | callback: function(err, result) { 53 | var expectedImage = new Canvas.Image(); 54 | expectedImage.src = fs.readFileSync(base + '.png'); 55 | var expectedCanvas = new Canvas(expectedImage.width, expectedImage.height); 56 | var ctx = expectedCanvas.getContext('2d'); 57 | ctx.drawImage(expectedImage, 0, 0, expectedImage.width, expectedImage.height); 58 | 59 | // real test 60 | expect(imagediff.equal(result, expectedCanvas, 20)).to.be.true; 61 | done(); 62 | } 63 | }); 64 | }); 65 | } 66 | 67 | }); 68 | -------------------------------------------------------------------------------- /test/cartoRenderer/markers.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { "type": "Feature", "properties": { "DC_NUM": "014848", "DC_KEY": "201001014848", "LOCATION_B": "2100 BLOCK S BROAD ST", "THEFT_DATE": "2010\/04\/16", "THEFT_YEAR": 2010, "DC_DIST": 1, "STOLEN_VAL": 150, "THEFT_HOUR": 0, "UCR": 625, "LAT": 39.9242733, "LNG": -75.169775454 }, "geometry": { "type": "Point", "coordinates": [ -75.169775453999989, 39.924273321000044 ] } }, 5 | { "type": "Feature", "properties": { "DC_NUM": "016330", "DC_KEY": "201001016330", "LOCATION_B": "S 2100 MCKEAN ST", "THEFT_DATE": "2010\/04\/24", "THEFT_YEAR": 2010, "DC_DIST": 1, "STOLEN_VAL": 215, "THEFT_HOUR": 17, "UCR": 615, "LAT": 39.9270745, "LNG": -75.180905401 }, "geometry": { "type": "Point", "coordinates": [ -75.180905400999961, 39.927074527000059 ] } }, 6 | { "type": "Feature", "properties": { "DC_NUM": "023568", "DC_KEY": "201001023568", "LOCATION_B": "2700 BLOCK SNYDER AVE", "THEFT_DATE": "2010\/06\/06", "THEFT_YEAR": 2010, "DC_DIST": 1, "STOLEN_VAL": 120, "THEFT_HOUR": 11, "UCR": 625, "LAT": 39.9271197, "LNG": -75.191034151 }, "geometry": { "type": "Point", "coordinates": [ -75.19103415099994, 39.927119746000074 ] } }, 7 | { "type": "Feature", "properties": { "DC_NUM": "028556", "DC_KEY": "201001028556", "LOCATION_B": "2100 BLOCK S GARNET ST", "THEFT_DATE": "2010\/07\/08", "THEFT_YEAR": 2010, "DC_DIST": 1, "STOLEN_VAL": 200, "THEFT_HOUR": 15, "UCR": 615, "LAT": 39.9254113, "LNG": -75.178257356 }, "geometry": { "type": "Point", "coordinates": [ -75.178257355999961, 39.925411335000035 ] } }, 8 | { "type": "Feature", "properties": { "DC_NUM": "029047", "DC_KEY": "201001029047", "LOCATION_B": "2100 BLOCK S 15TH ST", "THEFT_DATE": "2010\/07\/11", "THEFT_YEAR": 2010, "DC_DIST": 1, "STOLEN_VAL": 75, "THEFT_HOUR": 11, "UCR": 625, "LAT": 39.9241409, "LNG": -75.171456936 }, "geometry": { "type": "Point", "coordinates": [ -75.17145693599997, 39.924140875000035 ] } } 9 | ], 10 | "tile": { 11 | "x": 1192, 12 | "y": 1551, 13 | "z": 12 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/cartoRenderer/markers.mss: -------------------------------------------------------------------------------- 1 | Map { 2 | background-color: rgba(122, 122, 122, 1); 3 | } 4 | 5 | #data { 6 | marker-fill: #ff530d; 7 | marker-line-color: #fff; 8 | marker-width: 8; 9 | marker-line-width: 2; 10 | marker-allow-overlap: true; 11 | } 12 | -------------------------------------------------------------------------------- /test/cartoRenderer/markers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodetiles/nodetiles-core/117853de6935081f7ebefb19c766ab6c1bded244/test/cartoRenderer/markers.png -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require chai 2 | --require sinon 3 | --reporter spec 4 | --ui bdd 5 | --timeout 150000 -------------------------------------------------------------------------------- /test/projector.spec.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var sinon = require('sinon'); 3 | var projector = require(__dirname + '/../index').projector; 4 | 5 | // THIS IS THE CORRECT DATA 6 | // Requested tile: 12/654/1584 7 | // 8 | // Spherical Mercator (meters): 9 | // -13638811.83098057 4529964.044292685 10 | // -13629027.891360067 4539747.983913187 11 | // 12 | // WGS84 datum (longitude/latitude): 13 | // -122.51953125 37.64903402157866 14 | // -122.431640625 37.71859032558813 15 | 16 | describe('Projector', function() { 17 | 18 | var sandbox; 19 | beforeEach(function () { 20 | sandbox = sinon.sandbox.create(); 21 | }); 22 | 23 | afterEach(function () { 24 | sandbox.restore(); 25 | }); 26 | 27 | describe('utils', function() { 28 | it('Can correctly convert from Tiles to Spherical Mercator (meters)', function(done) { 29 | var tile = [12, 654, 1584]; 30 | var metersCorrect = [ 31 | -13638811.83098057, 4529964.044292685, 32 | -13629027.891360067, 4539747.983913187 33 | ] 34 | var outputMeters = projector.util.tileToMeters(tile[1], tile[2], tile[0]); 35 | 36 | expect(outputMeters[0]).to.equal(metersCorrect[0], 'should calculate minX'); 37 | expect(outputMeters[1]).to.equal(metersCorrect[1], 'should calculate minY'); 38 | expect(outputMeters[2]).to.equal(metersCorrect[2], 'should calculate maxX'); 39 | expect(outputMeters[3]).to.equal(metersCorrect[3], 'should calculate maxY'); 40 | done(); 41 | }); 42 | 43 | it('Can correctly convert from Tiles to Spherical Mercator (meters), zoom 14', function(done) { 44 | var tile = [14, 2617, 6333]; 45 | var metersCorrect = [ 46 | -13636365.846075444, 4544639.953723438, 47 | -13633919.861170318, 4547085.9386285655 48 | ] 49 | var outputMeters = projector.util.tileToMeters(tile[1], tile[2], tile[0]); 50 | 51 | expect(outputMeters[0]).to.equal(metersCorrect[0], 'should calculate minX'); 52 | expect(outputMeters[1]).to.equal(metersCorrect[1], 'should calculate minY'); 53 | expect(outputMeters[2]).to.equal(metersCorrect[2], 'should calculate maxX'); 54 | expect(outputMeters[3]).to.equal(metersCorrect[3], 'should calculate maxY'); 55 | done(); 56 | }) 57 | 58 | it('Can correctly convert from Tiles to Spherical Mercator (meters), zoom 13', function(done) { 59 | var tile = [13, 1308, 3166]; 60 | var metersCorrect = [ 61 | -13638811.83098057, 4544639.953723438, 62 | -13633919.861170318, 4549531.923533689 63 | ] 64 | var outputMeters = projector.util.tileToMeters(tile[1], tile[2], tile[0]); 65 | 66 | expect(outputMeters[0]).to.equal(metersCorrect[0], 'should calculate minX'); 67 | expect(outputMeters[1]).to.equal(metersCorrect[1], 'should calculate minY'); 68 | expect(outputMeters[2]).to.equal(metersCorrect[2], 'should calculate maxX'); 69 | expect(outputMeters[3]).to.equal(metersCorrect[3], 'should calculate maxY'); 70 | done(); 71 | }) 72 | }); 73 | 74 | describe("project (proj4)", function() { 75 | it('Point: converts a from lat/lon to spherical mercator meters', function() { 76 | var c = [-122.51953125, 37.75334401310656]; 77 | var metersCorrect = [-13638811.83098057, 4544639.953723437]; // or 438? 78 | 79 | var projected = projector.project.Point("EPSG:4326","EPSG:900913", c); 80 | expect(projected[0]).to.equal(metersCorrect[0], 'should calculate x'); 81 | expect(projected[1]).to.equal(metersCorrect[1], 'should calculate y'); 82 | }); 83 | 84 | it('Feature: converts polygon from lat/lon to spherical mercator meters', function() { 85 | var feature = { 86 | name: "Sunset", 87 | geometry: { 88 | "type": "Polygon", 89 | "coordinates": [ 90 | [ [-122.494142, 37.771126], 91 | [-122.473210, 37.768930], 92 | [-122.505069, 37.75324], 93 | [-122.468151, 37.763694] ] 94 | ] 95 | } 96 | }; 97 | var expectedFeature = { 98 | name: "Sunset", 99 | geometry: { 100 | "type": "Polygon", 101 | "coordinates": [ 102 | [ [-13635985.512598945, 4547143.855632217], 103 | [-13633655.37301766, 4546834.601778074], 104 | [-13637201.900674844, 4544625.309289527], 105 | [-13633092.207713738, 4546097.273993165] ] 106 | ] 107 | } 108 | }; 109 | var projected = projector.project.Feature("EPSG:4326","EPSG:900913", feature); 110 | expect(projected).to.deep.equal(expectedFeature, 'should calculate x'); 111 | }); 112 | 113 | it("should not use Proj4 for 4326 <-> 900913", function() { 114 | var Proj4js = require('proj4js'); 115 | var transform = sandbox.spy(Proj4js, "transform"); 116 | 117 | projector.project.Point("EPSG:4326","EPSG:900913", [-122.51953125, 37.75334401310656]); 118 | expect(transform.called).to.equal(false); 119 | 120 | projector.project.Point("EPSG:900913","EPSG:4326", [-122.51953125, 37.75334401310656]); 121 | expect(transform.called).to.equal(false); 122 | }); 123 | 124 | it("should use Proj4 for 4326 -> ??", function() { 125 | var Proj4js = require('proj4js'); 126 | var transform = sandbox.spy(Proj4js, "transform"); 127 | 128 | projector.project.Point("EPSG:4326","EPSG:2227", [-122.51953125, 37.75334401310656]); 129 | expect(transform.called).to.equal(true); 130 | }); 131 | 132 | it("should use Proj4 for 900913 -> ??", function() { 133 | var Proj4js = require('proj4js'); 134 | var transform = sandbox.spy(Proj4js, "transform"); 135 | 136 | projector.project.Point("EPSG:900913","EPSG:2227", [-122.51953125, 37.75334401310656]); 137 | expect(transform.called).to.equal(true); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /test_perf/projector.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var Benchmark = require('benchmark'); 3 | var projector = require("../lib/projector"); 4 | var projectorOld = require("../lib/projector.old"); 5 | 6 | var fileToProject = process.argv[2]; 7 | try { 8 | var data = require(fileToProject); 9 | } 10 | catch(error) { 11 | console.error("Could not load '" + fileToProject + "'"); 12 | process.exit(1); 13 | } 14 | 15 | var suite = new Benchmark.Suite; 16 | suite 17 | .add('FeatureCollection', function() { 18 | projector.project.FeatureCollection("EPSG:4326", "EPSG:900913", data); 19 | }) 20 | .add('Feature', function() { 21 | for (var i = data.features.length - 1; i > -1; i--) { 22 | projector.project.Feature("EPSG:4326", "EPSG:900913", data.features[i]); 23 | } 24 | }) 25 | .add('Old FeatureCollection', function() { 26 | projectorOld.project.FeatureCollection("EPSG:4326", "EPSG:900913", data); 27 | }) 28 | .add('Old Feature', function() { 29 | for (var i = data.features.length - 1; i > -1; i--) { 30 | projectorOld.project.Feature("EPSG:4326", "EPSG:900913", data.features[i]); 31 | } 32 | }) 33 | .on('cycle', function(event) { 34 | console.log(event.target.toString()); 35 | }) 36 | .run(); 37 | --------------------------------------------------------------------------------