Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Render only single shape #10

Merged
merged 3 commits into from May 21, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion .travis.yml
@@ -1,7 +1,6 @@
language: node_js
sudo: false
node_js:
- '4'
- '6'
before_install:
- npm install coveralls
Expand Down
51 changes: 39 additions & 12 deletions bin/geojson-thumbnail.js
Expand Up @@ -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('<input file> <output file>')
.description('Render a GeoJSON thumbnail')
.option('--background-satellite')
.option('--no-padding')
.option('--background-streets')
.option('--stylesheet <f>')
.option('--access-token <t>')
.option('--min-zoom <n>')
.option('--max-zoom <n>')
.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) => {
Expand All @@ -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);
}
}

131 changes: 52 additions & 79 deletions index.js
Expand Up @@ -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');
Expand All @@ -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);
}
});
}

Expand Down
62 changes: 62 additions & 0 deletions 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
};
}
7 changes: 5 additions & 2 deletions lib/styles.js
Expand Up @@ -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')
};
6 changes: 6 additions & 0 deletions lib/thumbnail.js
Expand Up @@ -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
};
}

/**
Expand All @@ -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');
Expand Down
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
},
Expand Down