diff --git a/.travis.yml b/.travis.yml index 6f700f1..a28674a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: node_js sudo: false node_js: -- '4' - '6' before_install: - npm install coveralls diff --git a/bin/geojson-thumbnail.js b/bin/geojson-thumbnail.js index 786f08b..0c07af0 100755 --- a/bin/geojson-thumbnail.js +++ b/bin/geojson-thumbnail.js @@ -5,35 +5,55 @@ const program = require('commander'); const fs = require('fs'); const path = require('path'); const sources = require('../lib/sources'); +const styles = require('../lib/styles'); const index = require('../index'); +const getStdin = require('get-stdin'); program .usage(' ') .description('Render a GeoJSON thumbnail') + .option('--background-satellite') + .option('--no-padding') + .option('--background-streets') + .option('--stylesheet ') + .option('--access-token ') .option('--min-zoom ') .option('--max-zoom ') .parse(process.argv); -const run = (input, output, minZoom, maxZoom) => { - const geojson = JSON.parse(fs.readFileSync(input)); - const options = { - backgroundTileJSON: sources.mapboxSatellite(process.env.MapboxAccessToken) - }; +function run(inputString, output, minZoom, maxZoom, satellite, streets, stylesheetPath, accessToken, padding) { + const geojson = JSON.parse(inputString); + const options = { }; + + if (satellite) { + options.background = { tilejson: sources.mapboxStellite(accessToken || process.env.MapboxAccessToken) }; + } else if (streets) { + options.background = { tilejson: sources.mapboxStreets(accessToken || process.env.MapboxAccessToken) }; + } + + if (stylesheetPath === 'black') { + options.stylesheet = styles.black; + } else if (stylesheetPath) { + options.stylesheet = fs.readFileSync(path.normalize(stylesheetPath), 'utf8'); + } else { + options.stylesheet = styles.default; + } if (output.endsWith('.png')) { - options.blendFormat = 'png'; + options.format = 'png'; } else if (output.endsWith('.jpg')) { - options.blendFormat = 'jpeg'; + options.format = 'jpeg'; } + options.noPadding = !padding; + if (maxZoom) { - options.thumbnailMaxZoom = maxZoom; + options.maxzoom = maxZoom; } if (minZoom) { - options.thumbnailMinZoom = minZoom; + options.minzoom = minZoom; } - index.renderThumbnail(geojson, function onImageRendered(err, image, headers, stats) { if (err) throw err; fs.writeFile(output, image, (err) => { @@ -46,11 +66,18 @@ const run = (input, output, minZoom, maxZoom) => { ); }); }, options); -}; +} if (program.args.length < 2) { program.outputHelp(); } else { - run(program.args[0], program.args[1], program.minZoom, program.maxZoom); + + if (program.args[0] === '-') { + getStdin().then((str) => { + run(str, program.args[1], parseInt(program.minZoom), parseInt(program.maxZoom), program.backgroundSatellite, program.backgroundStreets, program.stylesheet, program.accessToken, program.padding); + }); + } else { + run(fs.readFileSync(program.args[0]), program.args[1], parseInt(program.minZoom), parseInt(program.maxZoom), program.backgroundSatellite, program.backgroundStreets, program.stylesheet, program.accessToken, program.padding); + } } diff --git a/index.js b/index.js index aa42484..e8325f5 100644 --- a/index.js +++ b/index.js @@ -4,75 +4,50 @@ const styles = require('./lib/styles'); const template = require('./lib/template'); const thumbnail = require('./lib/thumbnail'); const blend = require('./lib/blend'); -const zoom = require('./lib/zoom'); +const bestRenderParams = require('./lib/renderparams'); const TileJSON = require('@mapbox/tilejson'); -const bbox = require('@turf/bbox'); const abaculus = require('@mapbox/abaculus'); -const sm = new (require('@mapbox/sphericalmercator'))(); -function bestRenderParams(geojson, backgroundTileJSON, minZoom, maxZoom) { - let optimalZoom = zoom.decideZoom(bbox(geojson)); - if (optimalZoom > backgroundTileJSON.maxzoom) { - optimalZoom = backgroundTileJSON.maxzoom; - } - if (optimalZoom < backgroundTileJSON.minzoom) { - optimalZoom = backgroundTileJSON.minzoom; - } - optimalZoom = Math.max(minZoom, Math.min(maxZoom, optimalZoom)); - - function paddedExtent(geojson) { - const extent = bbox(geojson); - - const topRight = sm.px([extent[2], extent[3]], optimalZoom); - const bottomLeft = sm.px([extent[0], extent[1]], optimalZoom); - const width = topRight[0] - bottomLeft[0]; - const height = bottomLeft[1] - topRight[1]; - const minSize = 200; - - // TODO: Padding is super hacky without any real background checking what we should do - let minPad = Math.abs(sm.ll([0, 0], optimalZoom)[0] - sm.ll([10, 10], optimalZoom)[0]); - - if (width < minSize) { - minPad = Math.abs(sm.ll([0, 0], optimalZoom)[0] - sm.ll([minSize - width, 10], optimalZoom)[0]); - } - if (height < minSize) { - minPad = Math.abs(sm.ll([0, 0], optimalZoom)[0] - sm.ll([minSize - height, 10], optimalZoom)[0]); - } - - const pad = Math.max( - Math.abs(extent[2] - extent[0]) * 0.05, - Math.abs(extent[3] - extent[1]) * 0.05, - minPad, - 0.001 - ); - extent[0] -= pad; - extent[1] -= pad; - extent[2] += pad; - extent[3] += pad; - return extent; - } +function renderOverlay(geojson, options, template, callback) { + const overlaySource = new thumbnail.ThumbnailSource(geojson, template, options.image, options.map); + const renderParams = Object.assign(bestRenderParams(geojson, options.minzoom, options.maxzoom, !!options.noPadding), { + format: options.blendFormat || 'png', + tileSize: options.tileSize, + getTile: overlaySource.getTile.bind(overlaySource) + }); + abaculus(renderParams, (err, image, headers) => { + callback(err, image, headers, overlaySource.stats); + }); +} - const bounds = paddedExtent(geojson); - return { - // ensure zoom is within min and max bounds configured for thumbnail - zoom: optimalZoom, - scale: 1, - format: 'png', - bbox: bounds, - limit: 36000, - tileSize: 256 - }; +function renderOverlayWithBackground(geojson, options, template, callback) { + const backgroundUri = { data: options.background.tilejson }; + new TileJSON(backgroundUri, (err, backgroundSource) => { + const overlaySource = new thumbnail.ThumbnailSource(geojson, template, options.image, options.map); + const blendSource = new blend.BlendRasterSource(backgroundSource, overlaySource); + const renderParams = Object.assign(bestRenderParams(geojson, options.minzoom, options.maxzoom), { + format: options.blendFormat || 'png', + tileSize: options.tileSize, + getTile: blendSource.getTile + }); + abaculus(renderParams, (err, image, headers) => { + callback(err, image, headers, blendSource.stats); + }); + }); } /** * Render a thumbnmail from a GeoJSON feature * @param {Object} geojson - GeoJSON Feature or FeatureCollection - * @param {Function} callback - Callback called with rendered imageonce finished + * @param {Function} callback - Callback called with rendered image once finished * @param {Object} options - * @param {Object} [options.backgroundTileJSON] - Provide a custom TileJSON for the background layer - * @param {Number} [options.thumbnailMinZoom] - Specify a min zoom level to render thumbnail - * @param {Number} [options.thumbnailMaxZoom] - Specify a max zoom level to render thumbnail - * @param {string} [options.blendFormat] - Format to use when blended together with the background image. https://github.com/mapbox/node-blend#options + * @param {Object} [options.image] - Image options + * @param {Object} [options.map] - Map options + * @param {Object} [options.background] - Render thumbnail on a background + * @param {Object} [options.background.tilejson] - TileJSON for the background layer + * @param {Number} [options.minzoom] - Specify a min zoom level to render thumbnail + * @param {Number} [options.maxzoom] - Specify a max zoom level to render thumbnail + * @param {string} [options.format] - Format to use when blended together with the background image. https://github.com/mapbox/node-blend#options */ function renderThumbnail(geojson, callback, options) { if (!geojson) throw new Error('Cannot render thumbnail without GeoJSON passed'); @@ -81,32 +56,30 @@ function renderThumbnail(geojson, callback, options) { if (typeof callback !== 'function') throw new Error('Callback needs to be a function not an object'); options = Object.assign({ - thumbnailMinZoom: 0, - thumbnailMaxZoom: 22, + noPadding: false, + minzoom: 0, + maxzoom: 22, stylesheet: styles.default, - // backgroundTileJSON: sources.naturalEarth(), - backgroundTileJSON: sources.mapboxSatellite(process.env.MapboxAccessToken) + tileSize: 256 }, options); - options.tileSize = options.backgroundTileJSON.tileSize || 256; - const imageOptions = { + options.image = Object.assign({ tileSize: options.tileSize - }; + }, options.image); + + // Background source zoom always limits the possible min and maxzoom + if (options.background && options.background.tilejson) { + options.minzoom = Math.max(options.minzoom, options.background.tilejson.minzoom); + options.maxzoom = Math.min(options.maxzoom, options.background.tilejson.maxzoom); + } template.templatizeStylesheet(options.stylesheet, (err, template) => { - const backgroundUri = { data: options.backgroundTileJSON }; - new TileJSON(backgroundUri, (err, backgroundSource) => { - const overlaySource = new thumbnail.ThumbnailSource(geojson, template, imageOptions, options.mapOptions); - const blendSource = new blend.BlendRasterSource(backgroundSource, overlaySource); - const renderParams = Object.assign(bestRenderParams(geojson, options.backgroundTileJSON, options.thumbnailMinZoom, options.thumbnailMaxZoom), { - format: options.blendFormat || 'png', - tileSize: options.tileSize, - getTile: blendSource.getTile - }); - abaculus(renderParams, (err, image, headers) => { - callback(err, image, headers, blendSource.stats); - }); - }); + // If no background specified we only render the overlay + if (options.background && options.background.tilejson) { + return renderOverlayWithBackground(geojson, options, template, callback); + } else { + return renderOverlay(geojson, options, template, callback); + } }); } diff --git a/lib/renderparams.js b/lib/renderparams.js new file mode 100644 index 0000000..b1ee918 --- /dev/null +++ b/lib/renderparams.js @@ -0,0 +1,62 @@ +'use strict'; +const zoom = require('./zoom'); +const bbox = require('@turf/bbox'); +const sm = new (require('@mapbox/sphericalmercator'))(); + +module.exports = bestRenderParams; + +function bestRenderParams(geojson, minZoom, maxZoom, noPadding) { + let optimalZoom = zoom.decideZoom(bbox(geojson)); + optimalZoom = Math.max(minZoom, Math.min(maxZoom, optimalZoom)); + + function addPadding(extent, pad) { + extent[0] -= pad; + extent[1] -= pad; + extent[2] += pad; + extent[3] += pad; + return extent; + } + + function paddedExtent(geojson) { + const extent = bbox(geojson); + + const topRight = sm.px([extent[2], extent[3]], optimalZoom); + const bottomLeft = sm.px([extent[0], extent[1]], optimalZoom); + const width = topRight[0] - bottomLeft[0]; + const height = bottomLeft[1] - topRight[1]; + const minSize = 200; + + // TODO: Padding is super hacky without any real background checking what we should do + let minPad = Math.abs(sm.ll([0, 0], optimalZoom)[0] - sm.ll([10, 10], optimalZoom)[0]); + + if (width < minSize) { + minPad = Math.abs(sm.ll([0, 0], optimalZoom)[0] - sm.ll([minSize - width, 10], optimalZoom)[0]); + } + if (height < minSize) { + minPad = Math.abs(sm.ll([0, 0], optimalZoom)[0] - sm.ll([minSize - height, 10], optimalZoom)[0]); + } + + const pad = Math.max( + Math.abs(extent[2] - extent[0]) * 0.05, + Math.abs(extent[3] - extent[1]) * 0.05, + minPad, + 0.001 + ); + extent[0] -= pad; + extent[1] -= pad; + extent[2] += pad; + extent[3] += pad; + return extent; + } + + const bounds = noPadding ? addPadding(bbox(geojson), 0.0001) : paddedExtent(geojson); + return { + // ensure zoom is within min and max bounds configured for thumbnail + zoom: optimalZoom, + scale: 1, + format: 'png', + bbox: bounds, + limit: 36000, + tileSize: 256 + }; +} diff --git a/lib/styles.js b/lib/styles.js index b8d7bd7..0183a25 100644 --- a/lib/styles.js +++ b/lib/styles.js @@ -5,7 +5,10 @@ const path = require('path'); module.exports = { /** * A default style that visualizes geometries - * @returns {string} Mapnik Stylesheet */ - default: fs.readFileSync(path.normalize(__dirname + '/../styles/default.xml'), 'utf8') + default: fs.readFileSync(path.normalize(__dirname + '/../styles/default.xml'), 'utf8'), + /** + * A style that draws black shapes for geoms + */ + black: fs.readFileSync(path.normalize(__dirname + '/../styles/black.xml'), 'utf8') }; diff --git a/lib/thumbnail.js b/lib/thumbnail.js index 1d35cf8..5c81b3c 100644 --- a/lib/thumbnail.js +++ b/lib/thumbnail.js @@ -24,6 +24,11 @@ class ThumbnailSource extends events.EventEmitter { this._bufferSize = 64; this._mapOptions = mapOptions || {}; this._xml = template.replace('{{geojson}}', JSON.stringify(geojson)); + + this.stats = { + requested: 0, + rendered: 0 + }; } /** @@ -42,6 +47,7 @@ class ThumbnailSource extends events.EventEmitter { try { // TODO: It is not smart or performant to create a new mapnik instance for each tile rendering + this.stats.rendered += 1; map.fromString(this._xml, this._mapOptions, function onMapLoaded(err) { if (err) return callback(err); map.extent = sm.bbox(x, y, z, false, '900913'); diff --git a/package-lock.json b/package-lock.json index ed65eb6..67f9cd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3757,6 +3757,11 @@ "integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=", "dev": true }, + "get-stdin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", + "integrity": "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==" + }, "get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", diff --git a/package.json b/package.json index 71f9ace..269d47e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Generate thumbnails for GeoJSON features", "main": "index.js", "engines": { - "node": "4.3" + "node": ">6.0" }, "bin": { "geojson-thumbnail": "bin/geojson-thumbnail.js" @@ -46,6 +46,7 @@ "@turf/distance": "^5.1.5", "@turf/helpers": "^6.0.0-beta.3", "commander": "^2.13.0", + "get-stdin": "^6.0.0", "mapnik": "^3.7.0", "xml2js": "^0.4.19" }, diff --git a/styles/black.mss b/styles/black.mss new file mode 100644 index 0000000..11827b9 --- /dev/null +++ b/styles/black.mss @@ -0,0 +1,36 @@ +@w-lines: 2; + +Map { + background-color: #fff; //For Style guide +} + +#features { + //Style for polygon + ['mapnik::geometry_type'=polygon] { + line-color:black; + polygon-fill: black; + } + + //Style for lines + ['mapnik::geometry_type'=linestring] { + + [zoom<14] { + line-width: @w-lines*1.5; + } + [zoom>=14] { + line-width: @w-lines*3; + } + + line-width: @w-lines; + line-cap:round; + } + + ['mapnik::geometry_type'=point] { + marker-width:12; + marker-type:ellipse; + marker-allow-overlap: true; + marker-ignore-placement: true; + marker-placement: point; + marker-fill: black; + } +} diff --git a/styles/black.xml b/styles/black.xml new file mode 100644 index 0000000..02fbe43 --- /dev/null +++ b/styles/black.xml @@ -0,0 +1,38 @@ + + + + + + -180,-85.0511,180,85.0511 + -4.2629,-2.753,16 + png8:m=h + 22 + 0 + + + + + + features + diff --git a/test/index.test.js b/test/index.test.js index d158037..6063d99 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -14,7 +14,7 @@ function assertThumbnailRenders(fixturePath, assert, options) { assert.true(image.length > 10 * 1024, `preview image should have reasonable image size ${image.length}`); assert.end(); }, Object.assign({ - backgroundTileJSON: sources.naturalEarth() + background: { tilejson: sources.naturalEarth() } }, options)); } @@ -36,20 +36,18 @@ tape('renderThumbnail peak', (assert) => { tape('renderThumbnail as png with better compression', (assert) => { assertThumbnailRenders('/fixtures/peak.geojson', assert, { - thumbnailEncoding: 'png8:m=h:z=8', - blendFormat: 'png' + format: 'png' }); }); tape('renderThumbnail as jpg', (assert) => { assertThumbnailRenders('/fixtures/peak.geojson', assert, { - thumbnailEncoding: 'jpeg80', - blendFormat: 'jpeg' + format: 'jpeg' }); }); tape('renderThumbnail with max zoom', (assert) => { assertThumbnailRenders('/fixtures/peak.geojson', assert, { - thumbnailMaxZoom: 4 + maxzoom: 4 }); });