UNPKG

carto

Version:

Mapnik Stylesheet Compiler

663 lines (590 loc) 22.6 kB
var _ = require('lodash'), carto = require('./index'), util = require('./util'); carto.Renderer = function Renderer(options) { this.env = {}; this.options = options || {}; // load different reference if given if (_.has(this.options, 'reference') && !_.isNil(this.options.reference)) { this.ref = new carto.tree.Reference(this.options.reference); } else { this.ref = new carto.tree.Reference(); } this.options.version = this.options.version || this.ref.getLatest(); this.options.outputFormat = this.options.outputFormat || 'mapnik'; this.env.ref = this.ref; if (_.has(this.options, 'quiet') && _.isBoolean(this.options.quiet)) { this.env.quiet = this.options.quiet; _.unset(this.options.quiet); } if (_.has(this.options, 'benchmark') && _.isBoolean(this.options.benchmark)) { this.env.benchmark = this.options.benchmark; _.unset(this.options.benchmark); } if (_.has(this.options, 'validationData')) { this.env.validation_data = this.options.validationData; _.unset(this.options.validationData); } if (_.has(this.options, 'ppi')) { this.env.ppi = this.options.ppi; _.unset(this.options.ppi); } if (_.has(this.options, 'effects')) { this.env.effects = this.options.effects; _.unset(this.options.effects); } if (_.has(this.options, 'filename')) { this.env.filename = this.options.filename; _.unset(this.options.filename); } }; /** * Prepare a MSS document (given as an string) into a * XML Style fragment (mostly useful for debugging) * * @param {String} data the mss contents as a string. */ carto.Renderer.prototype.renderMSS = function render(data) { // effects is a container for side-effects, which currently // are limited to FontSets. var env = _(this.env).defaults({ benchmark: false, validation_data: false, effects: [], quiet: false, }).value(); try { this.ref.setVersion(this.options.version); } catch (err) { util.error(env, { message: err.message }); return { msg: env.msg, data: null }; } // check if given reference is compliant with expectations if (!this.ref.compliant) { util.error(env, { message: 'Could not use the given reference, because it does not adhere to the specification. See the documentation for details.' }); return { msg: env.msg, data: null }; } var output = []; var styles = []; try { if (env.benchmark) console.time('Parsing MSS'); var parser = (carto.Parser(env)).parse(data); if (env.benchmark) console.timeEnd('Parsing MSS'); if (env.benchmark) console.time('Rule generation'); var rule_list = parser.toList(env); if (env.benchmark) console.timeEnd('Rule generation'); if (env.benchmark) console.time('Rule inheritance'); var rules = inheritDefinitions(rule_list, env); if (env.benchmark) console.timeEnd('Rule inheritance'); if (env.benchmark) console.time('Style sort'); var sorted = sortStyles(rules); if (env.benchmark) console.timeEnd('Style sort'); if (env.benchmark) console.time('Total Style generation'); for (var k = 0, rule, style_name; k < sorted.length; k++) { rule = sorted[k]; style_name = 'style' + (rule.attachment !== '__default__' ? '-' + rule.attachment : ''); styles.push(style_name); var bench_name = '\tStyle "'+style_name+'" (#'+k+') toXML'; if (env.benchmark) console.time(bench_name); // env.effects can be modified by this call output.push(carto.tree.StyleObject(style_name, rule.attachment, rule, env)); if (env.benchmark) console.timeEnd(bench_name); } if (env.benchmark) console.timeEnd('Total Style generation'); if (!util.hasErrors(env.msg)) { switch (this.options.outputFormat) { case 'json': output = JSON.stringify(output, null, 2); break; case 'mapnik': default: output = util.jsonToXML(output); break; } } else { output = null; } return { msg: env.msg, data: output }; } catch (err) { // do not swallow program errors if (err.message !== 'N/A') { throw err; } return { msg: env.msg, data: null }; } }; /** * Prepare a MML document (given as an object) into a * fully-localized XML file ready for Mapnik consumption * * @param {String} m - the JSON file as a string. */ carto.Renderer.prototype.render = function render(m) { // effects is a container for side-effects, which currently // are limited to FontSets. var env = _(this.env).defaults({ benchmark: false, validation_data: false, effects: [], ppi: 90.714, quiet: false }).value(); try { this.ref.setVersion(this.options.version); } catch (err) { util.error(env, { message: err.message }); return { msg: env.msg, data: null }; } // check if given reference is compliant with expectations if (!this.ref.compliant) { util.error(env, { message: 'Could not use the given reference, because it does not adhere to the specification. See the documentation for details.' }); return { msg: env.msg, data: null }; } var output = []; var definitions = []; function appliesTo(name, classIndex, zoom) { return function(definition) { return definition.appliesTo(name, classIndex, zoom); }; } try { // Transform stylesheets into definitions. if (_.has(m, 'Stylesheet') && !_.isNil(m.Stylesheet)) { m.Stylesheet = _.castArray(m.Stylesheet); definitions = _(m.Stylesheet).chain() .map(function(s) { if (_.isString(s) || !_.has(s, 'id') || !_.has(s, 'data') || _.isNil(s.id) || _.isNil(s.data)) { util.error(env, { message: "Expecting a stylesheet object of the form { id: 'x', 'data': 'y' } for the Stylesheet property." }); throw new Error('N/A'); } // Passing the environment from stylesheet to stylesheet, // allows frames and effects to be maintained. env = _(env).extend({filename:s.id}).value(); var time = +new Date(), root = (carto.Parser(env)).parse(s.data); if (env.benchmark) console.warn('Parsing time: ' + (new Date() - time) + 'ms'); return root.toList(env); }) .flatten() .value(); } // Iterate through layers and create styles custom-built // for each of them, and apply those styles to the layers. var styles, l, classIndex, rules, sorted, matching; for (var i = 0; i < m.Layer.length; i++) { l = m.Layer[i]; styles = []; if (_.has(l, 'id')) { l.layerId = l.id; } else { util.error(env, { message: 'The id attribute is required for layers.' }); return { msg: env.msg, data: null }; } if (definitions.length > 0) { classIndex = {}; if (env.benchmark) console.warn('processing layer: ' + l.id); // Classes are given as space-separated alphanumeric strings. var classes = (l['class'] || '').split(/\s+/g); for (var j = 0; j < classes.length; j++) { classIndex[classes[j]] = true; } // compile zoom range if given var zoom = 0, min = 0, max = Infinity, minOrMaxZoom = false; if (_.has(l, 'properties')) { if (_.has(l.properties, 'minzoom') && l.properties.minzoom > 0) { min = l.properties.minzoom; minOrMaxZoom = true; } if (_.has(l.properties, 'maxzoom') && l.properties.maxzoom <= carto.tree.Zoom.maxZoom) { max = l.properties.maxzoom; minOrMaxZoom = true; } } if (minOrMaxZoom) { for (var z = 0; z <= carto.tree.Zoom.maxZoom; z++) { if (z >= min && z <= max) { zoom |= (1 << z); } } matching = definitions.filter(appliesTo(l.layerId, classIndex, zoom)); } else { matching = definitions.filter(appliesTo(l.layerId, classIndex)); } rules = inheritDefinitions(matching, env); sorted = sortStyles(rules); if (sorted.length == 0) { util.warning(env, { message: 'Layer ' + l.layerId + ' has no styles associated with it.' }); } for (var k = 0, rule, style_name; k < sorted.length; k++) { rule = foldStyle(sorted[k]); style_name = l.layerId + (rule.attachment !== '__default__' ? '-' + rule.attachment : ''); // env.effects can be modified by this call var styleObj = carto.tree.StyleObject(style_name, rule.attachment, rule, env); if (Object.keys(styleObj).length) { output.push(styleObj); styles.push(style_name); } } } // if there is a global _properties object for this layer take it into account if (_.has(m, '_properties') && _.has(m._properties, l.layerId)) { if (!_.has(l, 'properties')) { l.properties = {}; } var props = {}; _.assign(props, m._properties[l.layerId], l.properties); l.properties = props; } output.push(carto.tree.LayerObject(l, styles, this.ref.data['version'])); } if (env.effects.length) { output = _.concat(env.effects.map(function(e) { return e.toObject(env); }), output); } var map_properties = getMapProperties(m, definitions, this.ref.data.symbolizers.map, env); if (!util.hasErrors(env.msg)) { // Pass TileJSON and other custom parameters through to Mapnik XML. var parameters = _(m).reduce(function(memo, v, k) { if (!v && v !== 0) return memo; switch (k) { // Known skippable properties. case 'srs': case 'Layer': case 'Stylesheet': break; // Non URL-bound TileJSON properties. case 'bounds': case 'center': case 'minzoom': case 'maxzoom': case 'version': case 'name': case 'description': case 'legend': case 'attribution': case 'template': case 'format': memo.push({ '_name': 'Parameter', '_attributes': { 'name': k }, '_content': v }); break; case 'interactivity': memo.push({ '_name': 'Parameter', '_attributes': { 'name': 'interactivity_layer' }, '_content': v.layer }); memo.push({ '_name': 'Parameter', '_attributes': { 'name': 'interactivity_fields' }, '_content': v.fields }); break; // Support any additional scalar properties. default: if ('string' === typeof v || 'number' === typeof v || 'boolean' === typeof v) { memo.push({ '_name': 'Parameter', '_attributes': { 'name': k }, '_content': v }); } break; } return memo; }, []); if (parameters.length) { output.unshift({ '_name': 'Parameters', '_content': parameters }); } // issue warnings for definitions that do not match layers _.forEach(definitions, function (v) { if (v.matchCount == 0) { var selectorName = '', filename = null, index = null; if (_.isArray(v.elements) && v.elements.length > 0) { selectorName = v.elements[v.elements.length - 1].value; } if (_.isArray(v.rules) && v.rules.length > 0) { filename = v.rules[0].filename; index = v.rules[0].index; } util.warning(env, { message: 'Styles do not match layer selector ' + selectorName + '.', filename: filename, index: index }); } }); output = { '_name': 'Map', '_attributes': map_properties, '_content': output }; switch (this.options.outputFormat) { case 'json': output = JSON.stringify(output, null, 2); break; case 'mapnik': default: output = '<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE Map[]>\n' + util.jsonToXML(output); break; } } else { output = null; } return { msg: env.msg, data: output }; } catch (err) { // do not swallow program errors if (err.message !== 'N/A') { throw err; } return { msg: env.msg, data: null }; } }; /** * This function currently modifies 'current' * @param {Array} current current list of rules * @param {Object} definition a Definition object to add to the rules * @param {Object} byFilter an object/dictionary of existing filters. This is * actually keyed `attachment->filter` */ function addRules(current, definition, byFilter) { var newFilters = definition.filters, newRules = definition.rules, updatedFilters, clone, previous; // The current definition might have been split up into // multiple definitions already. for (var k = 0; k < current.length; k++) { updatedFilters = current[k].filters.cloneWith(newFilters); if (updatedFilters) { previous = byFilter[updatedFilters]; if (previous) { // There's already a definition with those exact // filters. Add the current definitions' rules // and stop processing it as the existing rule // has already gone down the inheritance chain. previous.addRules(newRules); } else { clone = current[k].clone(updatedFilters); // Make sure that we're only maintaining the clone // when we did actually add rules. If not, there's // no need to keep the clone around. if (clone.addRules(newRules)) { // We inserted an element before this one, so we need // to make sure that in the next loop iteration, we're // not performing the same task for this element again, // hence the k++. byFilter[updatedFilters] = clone; current.splice(k, 0, clone); k++; } } } else if (updatedFilters === null) { // if updatedFilters is null, then adding the filters doesn't // invalidate or split the selector, so we addRules to the // combined selector // Filters can be added, but they don't change the // filters. This means we don't have to split the // definition. // // this is cloned here because of shared classes, see // sharedclass.mss current[k] = current[k].clone(); current[k].addRules(newRules); } // if updatedFeatures is false, then the filters split the rule, // so they aren't the same inheritance chain } return current; } /** * Apply inherited styles from their ancestors to them. * * called either once per render (in the case of mss) or per layer * (for mml) * * @param {Object} definitions - a list of definitions objects * that contain .rules * @param {Object} env - the environment * @return {Array<Array>} an array of arrays is returned, * in which each array refers to a specific attachment */ function inheritDefinitions(definitions, env) { var inheritTime = +new Date(); // definitions are ordered by specificity, // high (index 0) to low var byAttachment = {}, byFilter = {}; var result = []; var current, attachment; // Evaluate the filters specified by each definition with the given // environment to correctly resolve variable references definitions.forEach(function(d) { d.filters.ev(env); }); for (var i = 0; i < definitions.length; i++) { attachment = definitions[i].attachment; current = [definitions[i]]; if (!byAttachment[attachment]) { byAttachment[attachment] = []; byAttachment[attachment].attachment = attachment; byFilter[attachment] = {}; result.push(byAttachment[attachment]); } // Iterate over all subsequent rules. for (var j = i + 1; j < definitions.length; j++) { if (definitions[j].attachment === attachment) { // Only inherit rules from the same attachment. current = addRules(current, definitions[j], byFilter[attachment]); } } for (var k = 0; k < current.length; k++) { byFilter[attachment][current[k].filters] = current[k]; byAttachment[attachment].push(current[k]); } } if (env.benchmark) console.warn('Inheritance time: ' + ((new Date() - inheritTime)) + 'ms'); return result; } // Sort styles by the minimum index of their rules. // This sorts a slice of the styles, so it returns a sorted // array but does not change the input. function sortStylesIndex(a, b) { return b.index - a.index; } function sortStyles(styles) { for (var i = 0; i < styles.length; i++) { var style = styles[i]; style.index = Infinity; for (var b = 0; b < style.length; b++) { var rules = style[b].rules; for (var r = 0; r < rules.length; r++) { var rule = rules[r]; if (rule.index < style.index) { style.index = rule.index; } } } } var result = styles.slice(); result.sort(sortStylesIndex); return result; } // Removes dead style definitions that can never be reached // when filter-mode="first". The style is modified in-place // and returned. The style must be sorted. function foldStyle(style) { for (var i = 0; i < style.length; i++) { for (var j = style.length - 1; j > i; j--) { if (style[j].filters.cloneWith(style[i].filters) === null) { style.splice(j, 1); } } } return style; } /** * Find a rule like Map { background-color: #fff; }, * if any, and return a list of properties to be inserted * into the <Map element of the resulting XML. Translates * properties of the mml object at `m` directly into XML * properties. * * @param {Object} m the mml object. * @param {Array} definitions the output of toList. * @param {Array} symbolizers map symbolizers * @param {Object} env * @return {String} rendered properties. */ function getMapProperties(m, definitions, symbolizers, env) { var rules = {}; _(m).each(function(value, key) { if (key in symbolizers) rules[key] = value; }); definitions.filter(function(r) { return r.elements.join('') === 'Map'; }).forEach(function(r) { for (var i = 0; i < r.rules.length; i++) { var key = r.rules[i].name; if (!(key in symbolizers)) { util.error(env, { message: 'Rule ' + key + ' not allowed for Map.', index: r.rules[i].index }); } rules[key] = r.rules[i].ev(env).toObject(env)[key]; } }); return rules; } module.exports = carto; module.exports.addRules = addRules; module.exports.inheritDefinitions = inheritDefinitions; module.exports.sortStyles = sortStyles;