tilestrata-mapnik
Version:
A TileStrata plugin for rendering tiles with mapnik
380 lines (336 loc) • 12.2 kB
JavaScript
var fs = require('fs');
var crypto = require('crypto');
var Step = require('step');
var path = require('path');
var url = require('url');
var mapnik = require('mapnik');
var qs = require('querystring');
var Pool = require('generic-pool').Pool;
var LockingCache = require('./lockingcache');
// node-mapnik >= 1.3 no long auto-registers plugins
// so we do it here
if (mapnik.register_default_input_plugins) mapnik.register_default_input_plugins()
var cache = {};
exports = module.exports = MapnikSource;
require('util').inherits(MapnikSource, require('events').EventEmitter);
function MapnikSource(uri, callback) {
uri = this._normalizeURI(uri);
if (uri.protocol && uri.protocol !== 'mapnik:') {
throw new Error('Only the mapnik protocol is supported');
}
var key = JSON.stringify(uri);
// by default we use an internal self-caching mechanism but
// calling applications can pass `internal_cache:false` to disable
// TODO - consider removing completely once https://github.com/mapbox/tilemill/issues/1893
// is in place and a solid reference implementation of external caching
if (uri.query.internal_cache === false) {
this.once('open', callback);
this._open(uri);
} else {
// https://github.com/mapbox/tilelive-mapnik/issues/47
if (!cache[key]) {
cache[key] = this;
this._self_cache_key = key;
this._open(uri);
}
var source = cache[key];
source.setMaxListeners(0);
if (!source.open) {
source.once('open', function(err, source) {
if (err) cache[key] = false;
callback(err, source);
});
} else {
callback(null, source);
}
}
// cache used to skip encoding of solid images
this.solidCache = {};
return undefined;
}
MapnikSource.mapnik = mapnik;
MapnikSource.prototype.toJSON = function() {
return url.format(this._uri);
};
function as_bool(val) {
var num = +val;
return !isNaN(num) ? !!num : !!String(val).toLowerCase().replace(!!0,'');
}
MapnikSource.prototype._normalizeURI = function(uri) {
if (typeof uri === 'string') uri = url.parse(uri, true);
if (uri.hostname === '.' || uri.hostname == '..') {
uri.pathname = uri.hostname + uri.pathname;
delete uri.hostname;
delete uri.host;
}
if (typeof uri.pathname !== "undefined") uri.pathname = path.resolve(uri.pathname);
uri.query = uri.query || {};
if (typeof uri.query === 'string') uri.query = qs.parse(uri.query);
// cache self unless explicitly set to false
if (typeof uri.query.internal_cache === "undefined") uri.query.internal_cache = true;
else uri.query.internal_cache = as_bool(uri.query.internal_cache);
if (!uri.query.base) uri.query.base = '';
if (!uri.query.metatile) uri.query.metatile = 2;
if (!uri.query.resolution) uri.query.resolution = 4;
if (!uri.query.bufferSize) uri.query.bufferSize = 128;
if (!uri.query.tileSize) uri.query.tileSize = 256;
if (!uri.query.scale) uri.query.scale = 1;
// autoload fonts unless explicitly set to false
if (typeof uri.query.autoLoadFonts === "undefined") uri.query.autoLoadFonts = true;
else uri.query.autoLoadFonts = as_bool(uri.query.autoLoadFonts);
return uri;
};
// Finds all XML files in the filepath and returns their tilesource URI.
MapnikSource.list = function(filepath, callback) {
filepath = path.resolve(filepath);
fs.readdir(filepath, function(err, files) {
if (err) return callback(err);
for (var result = {}, i = 0; i < files.length; i++) {
var name = files[i].match(/^([\w-]+)\.xml$/);
if (name) result[name[1]] = 'mapnik://' + path.join(filepath, name[0]);
}
return callback(null, result);
});
};
// Finds an XML file with the given ID in the filepath and returns a
// tilesource URI.
/*
MapnikSource.findID = function(filepath, id, callback) {
filepath = path.resolve(filepath);
var file = path.join(filepath, id + '.xml');
fs.stat(file, function(err, stats) {
if (err) return callback(err);
else return callback(null, 'mapnik://' + file);
});
};
*/
MapnikSource.prototype._open = function(uri) {
var source = this;
function error(err) {
process.nextTick(function() {
source.emit('open', err);
});
}
this._stats = {
render: 0, // # of times a render is requested from mapnik
total: 0, // # of tiles returned from source
encoded: 0, // # of tiles encoded
solid: 0, // # of tiles isSolid
solidPainted: 0 // # of tiles isSolid && painted
};
this._internal_cache = uri.query.internal_cache;
this._autoLoadFonts = uri.query.autoLoadFonts;
this._base = uri.query.base;
uri.query.metatile = +uri.query.metatile;
uri.query.resolution = +uri.query.resolution;
uri.query.bufferSize = +uri.query.bufferSize;
uri.query.tileSize = +uri.query.tileSize;
this._uri = uri;
// Public API to announce how we're metatiling.
this.metatile = uri.query.metatile;
this.bufferSize = uri.query.bufferSize;
// This defaults to true. To disable font auto-loading
// Set ?autoLoadFonts=false in the mapnik URL to disable
if (this._autoLoadFonts) {
if (mapnik.register_default_fonts) mapnik.register_default_fonts();
if (mapnik.register_system_fonts) mapnik.register_system_fonts();
}
// Initialize this map. This wraps `localize()` and calls `create()`
// afterwards to actually create a new Mapnik map object.
Step(function() {
source._loadXML(this);
}, function(err, xml) {
if (err) return error(err);
// https://github.com/mapbox/tilelive-mapnik/issues/25
// there seems to be no value to assinging xml to a property
//source._xml = xml;
source._createMetatileCache();
source._createPool(xml, this);
}, function(err) {
if (err) return error(err);
source._populateInfo(this);
}, function(err) {
if (err) return error(err);
source.open = true;
source.emit('open', null, source);
});
};
MapnikSource.prototype.close = function(callback) {
this._close(function() {
return callback();
});
};
MapnikSource.prototype._cache = cache;
MapnikSource.prototype._close = function(callback) {
if (cache[this._self_cache_key]) delete cache[this._self_cache_key];
if (this._tileCache) this._tileCache.clear();
// https://github.com/coopernurse/node-pool/issues/17#issuecomment-6565795
if (this._pool) {
var pool = this._pool;
// NOTE: .drain() and .destoryAllNow() do
// not pass error args
pool.drain(function() {
pool.destroyAllNow(function() {
return callback();
});
});
}
};
MapnikSource.registerProtocols = function(tilelive) {
tilelive.protocols['mapnik:'] = MapnikSource;
};
// Loads the XML file from the specified path. Calls `callback` when the mapfile
// can be expected to be in `mapfile`. If this isn't successful, `callback` gets
// an error as its first argument.
MapnikSource.prototype._loadXML = function(callback) {
var source = this;
var pathname = this._uri.pathname;
this._base = pathname ? path.resolve(path.dirname(pathname)) : process.cwd();
// This is a string-based map file. Pass it on literally.
if (this._uri.xml) return callback(null, this._uri.xml);
// Load XML from file.
fs.readFile(path.resolve(source._uri.pathname), 'utf8', function(err, xml) {
if (err) return callback(err);
callback(null, xml);
});
};
// Create a new mapnik map object at `this.mapnik`. Requires that the mapfile
// be localized with `this.localize()`. This can be called in repetition because
// it won't recreate `this.mapnik`.
MapnikSource.prototype._createPool = function(xml, callback) {
var source = this;
if (!this._pool) {
var poolSize = source._uri.query.poolSize || process.env.UV_THREADPOOL_SIZE || require('os').cpus().length;
var poolOpts = { max: poolSize };
var poolFactory = {
create: function(callback) {
var map = new mapnik.Map(source._uri.query.tileSize, source._uri.query.tileSize);
map.bufferSize = source._uri.query.bufferSize;
var opts = {strict: source._uri.strict || false, base: source._base + '/'};
try {
map.fromString(xml, opts, function(err, map) {
if (err) return callback(err);
return callback(null, map);
});
} catch (err) {
return callback(err);
}
},
destroy: function(map) {
delete map;
},
};
this._pool = new Pool(Object.assign({}, poolOpts, poolFactory));
}
callback(null);
};
MapnikSource.prototype._populateInfo = function(callback) {
var source = this;
var id = this._uri.pathname
? path.basename(this._uri.pathname, path.extname(this._uri.pathname))
: '<raw xml>'
this._pool.acquire(function(err, map) {
if (err) return callback(err);
var info = { id: id, name: id, minzoom: 0, maxzoom: 22, autoscale: true };
var p = map.parameters;
for (var key in p) info[key] = p[key];
if (p.bounds) info.bounds = p.bounds.split(',').map(parseFloat);
if (p.center) info.center = p.center.split(',').map(parseFloat);
if (p.minzoom) info.minzoom = parseInt(p.minzoom, 10);
if (p.maxzoom) info.maxzoom = parseInt(p.maxzoom, 10);
if (p.interactivity_fields) info.interactivity_fields = p.interactivity_fields.split(',');
if (!info.bounds || info.bounds.length !== 4)
info.bounds = [ -180, -85.05112877980659, 180, 85.05112877980659 ];
if (!info.center || info.center.length !== 3) info.center = [
(info.bounds[2] - info.bounds[0]) / 2 + info.bounds[0],
(info.bounds[3] - info.bounds[1]) / 2 + info.bounds[1],
2 ];
source._info = info;
source._pool.release(map);
callback(null)
});
};
// Creates a locking cache that generates tiles. When requesting the same tile
// multiple times, they'll be grouped to one request.
MapnikSource.prototype._createMetatileCache = function() {
var source = this;
this._tileCache = new LockingCache(function(key) {
var cache = this;
var coords = key.split(',');
var keys = source._renderMetatile({
metatile: source._uri.query.metatile,
tileSize: source._uri.query.tileSize,
buffer_size: source.bufferSize,
format: coords[0],
z: +coords[1],
x: +coords[2],
y: +coords[3]
}, function(err, tiles) {
if (err) {
// Push error objects to all entries that were supposed to
// be generated.
keys.forEach(function(key) {
cache.put(key, err);
});
} else {
// Put all the generated tiles into the locking cache.
for (var key in tiles) {
cache.put(key, null, tiles[key].image, tiles[key].headers);
}
}
});
// Return a list of all the tile coordinates that are being rendered
// as part of this metatile.
return keys;
},
0); // purge immediately after callbacks
};
// Render handler for a given tile request.
MapnikSource.prototype.getTile = function(z, x, y, callback) {
z = +z; x = +x; y = +y;
if (isNaN(z) || isNaN(x) || isNaN(y)) {
return callback(new Error('Invalid coordinates: '+z+'/'+x+'/'+y));
}
var max = (1 << z);
if (x >= max || x < 0 || y >= max || y < 0) {
return callback(new Error('Coordinates out of range: '+z+'/'+x+'/'+y));
}
var format = (this._info && this._info.format) || 'png';
var key = [format, z, x, y].join(',');
this._tileCache.get(key, function(err, tile, headers) {
if (err) return callback(err);
callback(null, tile, headers);
});
};
MapnikSource.prototype.getGrid = function(z, x, y, callback) {
z = +z; x = +x; y = +y;
if (isNaN(z) || isNaN(x) || isNaN(y)) {
return callback(new Error('Invalid coordinates: '+z+'/'+x+'/'+y));
}
var max = (1 << z);
if (x >= max || x < 0 || y >= max || y < 0) {
return callback(new Error('Coordinates out of range: '+z+'/'+x+'/'+y));
} else if (!this._info ||
!this._info.interactivity_fields ||
!this._info.interactivity_layer) {
if (!this._info) {
return callback(new Error('Tilesource info is missing, cannot rendering interactivity'));
} else {
return callback(new Error('Tileset has no interactivity'));
}
} else if (!mapnik.supports.grid) {
return callback(new Error('Mapnik is missing grid support'));
}
var key = ['utf', z, x, y].join(',');
this._tileCache.get(key, function(err, grid, headers) {
if (err) return callback(err);
delete grid.solid;
callback(null, grid, headers);
});
};
MapnikSource.prototype.getInfo = function(callback) {
if (this._info) callback(null, this._info);
else callback(new Error('Info is unavailable'));
};
// Add other functions.
require('./render');