tangram
Version:
WebGL Maps for Vector Tiles
587 lines (530 loc) • 20.2 kB
JavaScript
import Utils from '../utils/utils';
import {compileFunctionString} from '../utils/functions';
import Geo from '../utils/geo';
import log from '../utils/log';
import parseCSSColor from 'csscolorparser';
const StyleParser = {};
export default StyleParser;
// Helpers for string converstion / NaN handling
const clampPositive = v => Math.max(v, 0);
const noNaN = v => isNaN(v) ? 0 : v;
const parseNumber = v => Array.isArray(v) ? v.map(parseFloat).map(noNaN) : noNaN(parseFloat(v));
const parsePositiveNumber = v => Array.isArray(v) ? v.map(parseNumber).map(clampPositive) : clampPositive(parseNumber(v));
Object.assign(StyleParser, {clampPositive, noNaN, parseNumber, parsePositiveNumber});
// Wraps style functions and provides a scope of commonly accessible data:
// - feature: the 'properties' of the feature, e.g. accessed as 'feature.name'
// - global: user-defined properties on the `global` object in the scene file
// - $zoom: the current map zoom level
// - $geometry: the type of geometry, 'point', 'line', or 'polygon'
// - $meters_per_pixel: conversion for meters/pixels at current map zoom
StyleParser.wrapFunction = function (func) {
var f = `
var feature = context.feature.properties;
var global = context.global;
var $zoom = context.zoom;
var $layer = context.layer;
var $source = context.source;
var $geometry = context.geometry;
var $meters_per_pixel = context.meters_per_pixel;
var $id = context.id;
var val = (function(){ ${func} }());
if (typeof val === 'number' && isNaN(val)) {
val = null; // convert NaNs to nulls
}
return val;
`;
return f;
};
// Style parsing
StyleParser.zeroPair = Object.freeze([0, 0]); // single allocation for zero values that won't be modified
// Style defaults
StyleParser.defaults = {
color: [1, 1, 1, 1],
width: 1,
size: 1,
extrude: false,
height: 20,
min_height: 0,
order: 0,
z: 0,
outline: {
color: [0, 0, 0, 0],
width: 0
},
material: {
ambient: 1,
diffuse: 1
}
};
// Style macros
StyleParser.macros = {
// pseudo-random color by geometry id
'Style.color.pseudoRandomColor': function() {
return [
0.7 * (parseInt(feature.id, 16) / 100 % 1), // eslint-disable-line no-undef
0.7 * (parseInt(feature.id, 16) / 10000 % 1), // eslint-disable-line no-undef
0.7 * (parseInt(feature.id, 16) / 1000000 % 1), // eslint-disable-line no-undef
1
];
},
// random color
'Style.color.randomColor': function() {
return [0.7 * Math.random(), 0.7 * Math.random(), 0.7 * Math.random(), 1];
}
};
// A context object that is passed to style parsing functions to provide a scope of commonly used values
StyleParser.getFeatureParseContext = function (feature, tile, global) {
return {
feature,
id: feature.id,
tile,
global,
zoom: tile.style_z,
geometry: Geo.geometryType(feature.geometry.type),
meters_per_pixel: tile.meters_per_pixel,
meters_per_pixel_sq: tile.meters_per_pixel_sq,
units_per_meter_overzoom: tile.units_per_meter_overzoom
};
};
// Build a style param cache object
// `value` is a raw value, cache methods will add other properties as needed
// `transform` is an optional, one-time transform function to run on values during setup
// `dynamic_transform` is an optional post-processing function applied to the result of function-based properties
const CACHE_TYPE = {
STATIC: 0,
DYNAMIC: 1,
ZOOM: 2
};
StyleParser.CACHE_TYPE = CACHE_TYPE;
StyleParser.createPropertyCache = function (obj, transform = null, dynamic_transform = null) {
if (obj == null) {
return;
}
if (obj.value) {
return { value: obj.value, zoom: (obj.zoom ? {} : null), type: obj.type }; // clone existing cache object
}
let c = { value: obj, type: CACHE_TYPE.STATIC };
// does value contain zoom stops to be interpolated?
if (Array.isArray(c.value) && Array.isArray(c.value[0])) {
c.zoom = {}; // will hold values interpolated by zoom
c.type = CACHE_TYPE.ZOOM;
}
else if (typeof c.value === 'function') {
c.type = CACHE_TYPE.DYNAMIC;
c.dynamic_transform = (typeof dynamic_transform === 'function' ? dynamic_transform : null);
}
// apply optional transform function - usually a parsing function
if (typeof transform === 'function') {
if (c.zoom) { // apply to each zoom stop value
c.value = c.value.map((v, i) => [v[0], transform(v[1], i)]);
}
else if (typeof c.value !== 'function') { // don't transform functions
c.value = transform(c.value, 0); // single value, 0 = the first and only item in the array
}
}
return c;
};
// Convert old-style color macro into a function
// TODO: deprecate this macro syntax
StyleParser.createColorPropertyCache = function (obj) {
return StyleParser.createPropertyCache(obj, v => {
if (v === 'Style.color.pseudoRandomColor') {
return compileFunctionString(StyleParser.wrapFunction(StyleParser.macros['Style.color.pseudoRandomColor']));
}
else if (v === 'Style.color.randomColor') {
return StyleParser.macros['Style.color.randomColor'];
}
return v;
});
};
// Parse point sizes, which include optional %-based or aspect-ratio-constrained scaling from sprite size
// Returns a cache object if successful, otherwise throws error message
const isPercent = v => typeof v === 'string' && v[v.length-1] === '%'; // size computed by %
const isRatio = v => v === 'auto'; // size derived from aspect ratio of one dimension
const isComputed = v => isPercent(v) || isRatio(v);
const dualRatioError = '\'size\' can specify either width or height as derived from aspect ratio, but not both';
StyleParser.createPointSizePropertyCache = function (obj, texture) {
// obj is the value to be parsed eg "64px" "100%" "auto"
// mimics the structure of the size value (at each zoom stop if applicable),
// stores flags indicating if each element is a %-based size or not, or derived from aspect
let has_pct = null;
let has_ratio = null;
if (isPercent(obj)) { // 1D size
has_pct = [true];
}
else if (Array.isArray(obj)) {
// track which fields are % vals
if (Array.isArray(obj[0])) { // zoom stops
// could be a 1D value (that could be a %), or a 2D value (either width or height or both could be a %)
if (obj.some(v => Array.isArray(v[1]) ? v[1].some(w => isComputed(w)) : isPercent(v[1]))) {
has_pct = obj.map(v => Array.isArray(v[1]) ? v[1].map(w => isPercent(w)) : isPercent(v[1]));
has_ratio = obj.map(v => Array.isArray(v[1]) && v[1].map(w => isRatio(w)));
if (has_ratio.some(v => Array.isArray(v) && v.every(c => c))) {
throw dualRatioError; // invalid case where both dims are ratios
}
}
}
else if (obj.some(isComputed)) { // 2D size
has_pct = [obj.map(isPercent)];
has_ratio = [obj.map(isRatio)];
if (has_ratio[0].every(c => c)) {
throw dualRatioError; // invalid case where both dims are ratios
}
}
}
else if (isRatio(obj)) {
throw 'this value only allowed as half of an array, eg [16px, auto]:';
// TODO: add this error check for zoom stop parsing above
}
if (has_pct || has_ratio) {
// texture is required when % or ratio sizes are used
if (!texture) {
throw '% or \'auto\' keywords can only be used to specify point size when a texture is defined';
}
// per-sprite based evaluation
obj = { value: obj };
obj.has_pct = has_pct;
obj.has_ratio = has_ratio;
obj.sprites = {}; // cache by sprite
}
else {
// no % or aspect ratio sizing, one cache for texture or all sprites
obj = StyleParser.createPropertyCache(obj, parsePositiveNumber);
}
return obj;
};
StyleParser.evalCachedPointSizeProperty = function (val, sprite_info, texture_info, context) {
if (val == null) {
return;
}
// no percentage-based calculation, one cache for all sprites
if (!val.has_pct && !val.has_ratio) {
return StyleParser.evalCachedProperty(val, context);
}
if (sprite_info) {
// per-sprite based evaluation, cache sizes per sprite
if (!val.sprites[sprite_info.sprite]) {
val.sprites[sprite_info.sprite] = createPointSizeCacheEntry(val, sprite_info);
}
return StyleParser.evalCachedProperty(val.sprites[sprite_info.sprite], context);
}
else {
// texture-based evaluation
// apply percentage or ratio sizing to a texture
val.texture = val.texture || createPointSizeCacheEntry(val, texture_info);
return StyleParser.evalCachedProperty(val.texture, context);
}
};
function createPointSizeCacheEntry (val, image_info) {
// the cache property transform function needs access to the image in `val`
// so it's accessed via a closure here
return StyleParser.createPropertyCache(val.value, (v, i) => {
if (Array.isArray(v)) { // 2D size
// either width or height or both could be a %
v = v.
map((c, j) => val.has_ratio[i][j] ? c : parsePositiveNumber(c)). // convert non-ratio values to px
map((c, j) => val.has_pct[i][j] ? image_info.css_size[j] * c / 100 : c); // apply % scaling as needed
// either width or height could be a ratio
if (val.has_ratio[i][0]) {
v[0] = v[1] * image_info.aspect;
}
else if (val.has_ratio[i][1]) {
v[1] = v[0] / image_info.aspect;
}
}
else { // 1D size
v = parsePositiveNumber(v);
if (val.has_pct[i]) {
v = image_info.css_size.map(c => c * v / 100); // set size as % of image
}
else {
v = [v, v]; // expand 1D size to 2D
}
}
return v;
});
}
// Interpolation and caching for a generic property (not a color or distance)
// { value: original, static: val, zoom: { 1: val1, 2: val2, ... }, dynamic: function(){...} }
StyleParser.evalCachedProperty = function(val, context) {
if (val == null) {
return;
}
else if (val.dynamic) { // function, compute each time (no caching)
return tryEval(val.dynamic, context);
}
else if (val.static) { // single static value
return val.static;
}
else if (val.zoom && val.zoom[context.zoom]) { // interpolated, cached
return val.zoom[context.zoom];
}
else { // not yet evaulated for cache
// Dynamic function-based
if (typeof val.value === 'function') {
if (val.dynamic_transform) {
// apply an optional post-eval transform function
// e.g. apply device pixel ratio to font sizes, unit conversions, etc.
val.dynamic = function(context) {
return val.dynamic_transform(val.value(context));
};
}
else {
val.dynamic = val.value;
}
return tryEval(val.dynamic, context);
}
// Array of zoom-interpolated stops, e.g. [zoom, value] pairs
else if (Array.isArray(val.value) && Array.isArray(val.value[0])) {
// Calculate value for current zoom
val.zoom = val.zoom || {};
val.zoom[context.zoom] = Utils.interpolate(context.zoom, val.value);
return val.zoom[context.zoom];
}
// Single static value
else {
val.static = val.value;
return val.static;
}
}
};
StyleParser.convertUnits = function(val, context) {
// pre-parsed units
if (val.value != null) {
if (val.units === 'px') { // convert from pixels
return val.value * Geo.metersPerPixel(context.zoom);
}
return val.value;
}
// un-parsed unit string
else if (typeof val === 'string') {
if (val.trim().slice(-2) === 'px') {
val = parseNumber(val);
val *= Geo.metersPerPixel(context.zoom); // convert from pixels
}
else {
val = parseNumber(val);
}
}
// multiple values or stops
else if (Array.isArray(val)) {
// Array of arrays, e.g. zoom-interpolated stops
if (Array.isArray(val[0])) {
return val.map(v => [v[0], StyleParser.convertUnits(v[1], context)]);
}
// Array of values
else {
return val.map(v => StyleParser.convertUnits(v, context));
}
}
return val;
};
// Pre-parse units from string values
StyleParser.parseUnits = function (value) {
var obj = { value: parseNumber(value) };
if (obj.value !== 0 && typeof value === 'string' && value.trim().slice(-2) === 'px') {
obj.units = 'px';
}
return obj;
};
// Takes a distance cache object and returns a distance value for this zoom
// (caching the result for future use)
// { value: original, zoom: { z: meters }, dynamic: function(){...} }
StyleParser.evalCachedDistanceProperty = function(val, context) {
if (val == null) {
return;
}
else if (val.dynamic) {
return tryEval(val.dynamic, context);
}
else if (val.zoom && val.zoom[context.zoom]) {
return val.zoom[context.zoom];
}
else {
// Dynamic function-based
if (typeof val.value === 'function') {
val.dynamic = val.value;
return tryEval(val.dynamic, context);
}
// Array of zoom-interpolated stops, e.g. [zoom, value] pairs
else if (val.zoom) {
// Calculate value for current zoom
// Do final unit conversion as late as possible, when interpolation values have been determined
val.zoom[context.zoom] = Utils.interpolate(context.zoom, val.value,
v => StyleParser.convertUnits(v, context));
return val.zoom[context.zoom];
}
else {
return StyleParser.convertUnits(val.value, context);
}
}
};
// Cache previously parsed color strings
StyleParser.string_colors = {};
StyleParser.colorForString = function(string) {
// Cached
if (StyleParser.string_colors[string]) {
return StyleParser.string_colors[string];
}
// Calculate and cache
let color = parseCSSColor.parseCSSColor(string);
if (color && color.length === 4) {
color[0] /= 255;
color[1] /= 255;
color[2] /= 255;
}
else {
color = StyleParser.defaults.color;
}
StyleParser.string_colors[string] = color;
return color;
};
// Takes a color cache object and returns a color value for this zoom
// (caching the result for future use)
// { value: original, static: [r,g,b,a], zoom: { z: [r,g,b,a] }, dynamic: function(){...} }
StyleParser.evalCachedColorProperty = function(val, context = {}) {
if (val == null) {
return;
}
else if (val.dynamic) {
let v = tryEval(val.dynamic, context);
if (typeof v === 'string') {
v = StyleParser.colorForString(v);
}
if (v && v[3] == null) {
v[3] = 1; // default alpha
}
return v;
}
else if (val.static) {
return val.static;
}
else if (val.zoom && val.zoom[context.zoom]) {
return val.zoom[context.zoom];
}
else {
// Dynamic function-based color
if (typeof val.value === 'function') {
val.dynamic = val.value;
let v = tryEval(val.dynamic, context);
if (typeof v === 'string') {
v = StyleParser.colorForString(v);
}
if (v && v[3] == null) {
v[3] = 1; // default alpha
}
return v;
}
// Single string color
else if (typeof val.value === 'string') {
val.static = StyleParser.colorForString(val.value);
return val.static;
}
// Array of zoom-interpolated stops, e.g. [zoom, color] pairs
else if (val.zoom) {
// Parse any string colors inside stops, the first time we encounter this property
if (!val.zoom_preprocessed) {
for (let i=0; i < val.value.length; i++) {
let v = val.value[i];
if (v && typeof v[1] === 'string') {
v[1] = StyleParser.colorForString(v[1]);
}
}
val.zoom_preprocessed = true;
}
// Calculate color for current zoom
val.zoom[context.zoom] = Utils.interpolate(context.zoom, val.value);
val.zoom[context.zoom][3] = val.zoom[context.zoom][3] || 1; // default alpha
return val.zoom[context.zoom];
}
// Single array color
else {
val.static = val.value.map(x => x); // copy to avoid modifying
if (val.static && val.static[3] == null) {
val.static[3] = 1; // default alpha
}
return val.static;
}
}
};
// Evaluate color cache object and apply optional alpha override (alpha arg is a single value cache object)
StyleParser.evalCachedColorPropertyWithAlpha = function (val, alpha_prop, context) {
const color = StyleParser.evalCachedColorProperty(val, context);
if (color != null && alpha_prop != null) {
const alpha = StyleParser.evalCachedProperty(alpha_prop, context);
if (alpha != null) {
return [color[0], color[1], color[2], alpha];
}
}
return color;
};
StyleParser.parseColor = function(val, context = {}) {
if (typeof val === 'function') {
val = tryEval(val, context);
}
// Parse CSS-style colors
// TODO: change all colors to use 0-255 range internally to avoid dividing and then re-multiplying in geom builder
if (typeof val === 'string') {
val = StyleParser.colorForString(val);
}
else if (Array.isArray(val) && Array.isArray(val[0])) {
// Array of zoom-interpolated stops, e.g. [zoom, color] pairs
for (let i=0; i < val.length; i++) {
let v = val[i];
if (typeof v[1] === 'string') {
v[1] = StyleParser.colorForString(v[1]);
}
}
if (context.zoom) {
val = Utils.interpolate(context.zoom, val);
}
}
// Defaults
if (Array.isArray(val)) {
val = val.map(x => x); // copy to avoid modifying
// alpha
if (val[3] == null) {
val[3] = 1;
}
}
else {
val = [0, 0, 0, 1];
}
return val;
};
StyleParser.calculateOrder = function(order, context) {
// Computed order
if (typeof order === 'function') {
order = tryEval(order, context);
}
else if (typeof order === 'string') {
// Order tied to feature property
if (context.feature.properties[order]) {
order = context.feature.properties[order];
}
// Explicit order value
else {
order = parsePositiveNumber(order);
}
}
return order;
};
// Evaluate a function-based property, or pass-through static value
StyleParser.evalProperty = function(prop, context) {
if (typeof prop === 'function') {
return tryEval(prop, context);
}
return prop;
};
// eval property function with try/catch
function tryEval (func, context) {
try {
return func(context);
} catch(e) {
log('warn',
`Property function in layer '${context.layers[context.layers.length-1]}' failed with\n`,
`error ${e.stack}\n`,
`function '${func.source}'\n`,
context.feature, context);
}
}