UNPKG

geostyler-openlayers-parser

Version:

GeoStyler Style Parser implementation for OpenLayers styles

524 lines 21.1 kB
import { isGeoStylerBooleanFunction, isGeoStylerFunction, isGeoStylerNumberFunction, isGeoStylerStringFunction, isGeoStylerUnknownFunction } from 'geostyler-style'; import { colors } from './colors'; const WELLKNOWNNAME_TTF_REGEXP = /^ttf:\/\/(.+)#(.+)$/; export const DUMMY_MARK_SYMBOLIZER_FONT = 'geostyler-mark-symbolizer'; /** * Offers some utility functions to work with OpenLayers Styles. */ class OlStyleUtil { /** * Transforms a HEX encoded color and an opacity value to a RGB(A) notation. * * @param colorString HEX encoded color * @param opacity Opacity (Betweeen 0 and 1) * @return the RGB(A) value of the input color */ static getRgbaColor(colorString, opacity) { if (isGeoStylerStringFunction(colorString)) { colorString = OlStyleUtil.evaluateStringFunction(colorString); } if (typeof (colorString) !== 'string') { return; } if (colorString.startsWith('rgba(')) { return colorString; } // check if is valid HEX color - see also here // https://stackoverflow.com/questions/8027423/how-to-check-if-a-string-is-a-valid-hex-color-representation/8027444 const isHexColor = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(colorString); if (!isHexColor) { return; } const r = parseInt(colorString.slice(1, 3), 16); const g = parseInt(colorString.slice(3, 5), 16); const b = parseInt(colorString.slice(5, 7), 16); if (isGeoStylerNumberFunction(opacity)) { opacity = OlStyleUtil.evaluateNumberFunction(opacity); } if (opacity < 0) { opacity = 1; } return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + opacity + ')'; } /** * Splits a RGBA encoded color into its color values. * * @param {string} rgbaColor RGB(A) encoded color * @return {number[]} Numeric color values as array */ static splitRgbaColor(rgbaColor) { const colorsOnly = rgbaColor.substring(rgbaColor.indexOf('(') + 1, rgbaColor.lastIndexOf(')')).split(/,\s*/); const red = parseInt(colorsOnly[0], 10); const green = parseInt(colorsOnly[1], 10); const blue = parseInt(colorsOnly[2], 10); const opacity = parseFloat(colorsOnly[3]); return [red, green, blue, opacity]; } /** * Transforms a RGB(A) or named color value to a HEX encoded notation. * If a HEX color is provided it will be returned untransformed. * * @param {string} inColor The color to transform * @return {string | undefined} The HEX color representation of the given color */ static getHexColor(inColor) { // if passing in a hex code we just return it if (inColor.startsWith('#')) { return inColor; } else if (inColor.startsWith('rgb')) { const colorArr = OlStyleUtil.splitRgbaColor(inColor); return OlStyleUtil.getHexCodeFromRgbArray(colorArr); } else if (colors[inColor.toLocaleLowerCase()] !== undefined) { const rgbColorArr = colors[inColor.toLocaleLowerCase()]; return OlStyleUtil.getHexCodeFromRgbArray(rgbColorArr); } else { return; } } /** * Returns the hex code for a given RGB(A) array. * * @param colorArr RGB(A) array. e.g. [255,0,0] * @return {string} The HEX color representation of the given color */ static getHexCodeFromRgbArray(colorArr) { return '#' + colorArr.map((x, idx) => { const hex = x.toString(16); // skip opacity if passed as fourth entry if (idx < 3) { return hex.length === 1 ? '0' + hex : hex; } return ''; }).join(''); } /** * Returns the opacity value of a RGB(A) color value. * * @param color RGBA or hex encoded color * @return {string | undefined} The opacity value of the given RGBA color */ static getOpacity(color) { const isRgba = color.startsWith('rgba('); const isHexWithAlpha = color.startsWith('#') && color.length > 7; if (!isRgba && !isHexWithAlpha) { return; } let colorArr; if (isRgba) { colorArr = OlStyleUtil.splitRgbaColor(color); } else { const hexOpacity = parseInt(color.slice(7, 9), 16) / 255; colorArr = [undefined, undefined, undefined, hexOpacity]; } if (colorArr.length === 4) { return colorArr[3]; } else { return; } } /** * Returns an OL compliant font string. * * @param symbolizer The TextSymbolizer to derive the font string from */ static getTextFont(symbolizer) { const fontWeight = symbolizer.fontWeight ?? 'normal'; const fontStyle = symbolizer.fontStyle ?? 'normal'; const size = symbolizer.size; const font = symbolizer.font; return fontWeight + ' ' + fontStyle + ' ' + size + 'px ' + font?.join(', '); } /** * Returns true if the given mark symbolizer is based on a font glyph * (i.e. has a well known name property starting with 'ttf://'). * * @param symbolizer The TextSymbolizer to derive the font string from */ static getIsFontGlyphBased(symbolizer) { return WELLKNOWNNAME_TTF_REGEXP.test(symbolizer.wellKnownName); } /** * Returns whether the given font (as used in the OpenLayers Text Style `font` property) * is intended for a mark symbolizer or not. * This is done by checking whether the dummy DUMMY_MARK_SYMBOLIZER_FONT font name is present. * * @param font The text font to analyze */ static getIsMarkSymbolizerFont(font) { if (!font) { return false; } const search = DUMMY_MARK_SYMBOLIZER_FONT; return font.substring(font.length - search.length, font.length) === search; } /** * Returns an OL compliant font string, to be used for mark symbolizers * using a font glyph. * This also includes a dummy DUMMY_MARK_SYMBOLIZER_FONT font name at the end of the * string to allow determining that this font was intended for a mark symbolizer * later on. * * @param symbolizer The TextSymbolizer to derive the font string from */ static getTextFontForMarkSymbolizer(symbolizer) { const parts = symbolizer.wellKnownName.match(WELLKNOWNNAME_TTF_REGEXP); if (!parts) { throw new Error(`Could not parse font-based well known name: ${symbolizer.wellKnownName}`); } const fontFamily = parts[1]; return `Normal ${symbolizer.radius || 5}px '${fontFamily}', ${DUMMY_MARK_SYMBOLIZER_FONT}`; } /** * Returns a 1-char string to be used as text for mark symbolizers using a font glyph. * * @param symbolizer The MarkSymbolizer to derive the character string from */ static getCharacterForMarkSymbolizer(symbolizer) { const parts = symbolizer.wellKnownName.match(WELLKNOWNNAME_TTF_REGEXP); if (!parts) { throw new Error(`Could not parse font-based well known name: ${symbolizer.wellKnownName}`); } return String.fromCharCode(parseInt(parts[2], 16)); } /** * Returns the font name used in the OpenLayers text style `font` property. * * @param olFont the `font` property of an OpenLayers text style */ static getFontNameFromOlFont(olFont) { const parts = olFont.match(/(?:\d+\S+) '?"?([^,'"]+)/); if (!parts) { throw new Error(`Could not find font family name in the following string: ${olFont}`); } return parts[1]; } /** * Returns the size in pixels specified in the OpenLayers text style `font` property, * or 0 if not found. * * @param olFont the `font` property of an OpenLayers text style */ static getSizeFromOlFont(olFont) { const parts = olFont.match(/(?:(\d+)px)/); return parts ? parseInt(parts[1], 10) : 0; } /** * Resolves the given template string with the given feature attributes, e.g. * the template "Size of area is {{AREA_SIZE}} km²" would be to resolved * to "Size of area is 1909 km²" (assuming the feature's attribute AREA_SIZE * really exists). * * @param feature The feature to get the attributes from. * @param template The template string to resolve. * @param [noValueFoundText] The text to apply, if the templated value * could not be found, default is to 'n.v.'. * @param [valueAdjust] A method that will be called with each * key/value match, we'll use what this function returns for the actual * replacement. Optional, defaults to a function which will return the raw * value it received. This can be used for last minute adjustments before * replacing happens, e.g. to filter out falsy values or to do number * formatting and such. * @return The resolved template string. */ static resolveAttributeTemplate(feature, template, noValueFoundText = 'n.v.', valueAdjust = (key, val) => val) { const attributeTemplatePrefix = '\\{\\{'; const attributeTemplateSuffix = '\\}\\}'; // Find any character between two braces (including the braces in the result) const regExp = new RegExp(attributeTemplatePrefix + '(.*?)' + attributeTemplateSuffix, 'g'); const regExpRes = template.match(regExp); // If we have a regex result, it means we found a placeholder in the // template and have to replace the placeholder with its appropriate value. if (regExpRes) { // Iterate over all regex match results and find the proper attribute // for the given placeholder, finally set the desired value to the hover. // field text regExpRes.forEach(res => { // We count every non matching candidate. If this count is equal to // the objects length, we assume that there is no match at all and // set the output value to the value of "noValueFoundText". let noMatchCnt = 0; for (const [key, value] of Object.entries(feature.getProperties())) { // Remove the suffixes and find the matching attribute column. const attributeName = res.slice(2, res.length - 2); if (attributeName.toLowerCase() === key.toLowerCase()) { template = template.replace(res, valueAdjust(key, value)); break; } else { noMatchCnt++; } } // No key match found for this feature (e.g. if key not // present or value is null). if (noMatchCnt === Object.keys(feature.getProperties()).length) { template = template.replace(res, noValueFoundText); } }); } return template; } static evaluateFunction(func, feature) { if (func.name === 'property') { if (!feature) { throw new Error(`Could not evalute 'property' function. Feature ${feature} is not defined.`); } if (isGeoStylerStringFunction(func.args[0])) { return feature?.get(OlStyleUtil.evaluateStringFunction(func.args[0], feature)); } else { return feature?.get(func.args[0]); } } if (isGeoStylerUnknownFunction(func)) { return OlStyleUtil.evaluateUnknownFunction(func, feature); } if (isGeoStylerStringFunction(func)) { return OlStyleUtil.evaluateStringFunction(func, feature); } if (isGeoStylerNumberFunction(func)) { return OlStyleUtil.evaluateNumberFunction(func, feature); } if (isGeoStylerBooleanFunction(func)) { return OlStyleUtil.evaluateBooleanFunction(func, feature); } return; } static evaluateBooleanFunction(func, feature) { const args = func.args.map(arg => { if (isGeoStylerFunction(arg)) { return OlStyleUtil.evaluateFunction(arg, feature); } return arg; }); switch (func.name) { case 'all': return args.map(arg => this.evaluateBooleanFunction(arg, feature)) .every(result => result === true); case 'any': return args.map(arg => this.evaluateBooleanFunction(arg, feature)) .some(result => result === true); case 'between': return args[0] >= args[1] && args[0] <= args[2]; case 'double2bool': // TODO: evaluate this correctly return false; case 'equalTo': return args[0] === args[1]; case 'greaterThan': return args[0] > args[1]; case 'greaterThanOrEqualTo': return args[0] >= args[1]; case 'in': return args.slice(1).includes(args[0]); case 'lessThan': return args[0] < args[1]; case 'lessThanOrEqualTo': return args[0] <= args[1]; case 'not': return !args[0]; case 'notEqualTo': return args[0] !== args[1]; case 'parseBoolean': return !!args[0]; case 'strEndsWith': return args[0].endsWith(args[1]); case 'strEqualsIgnoreCase': return args[0].toLowerCase() === args[1].toLowerCase(); case 'strMatches': { const regEx = args[1]; const regexArray = regEx.match(/\/(.*?)\/([gimy]{0,4})$/); if (regexArray && regexArray.length === 3) { return new RegExp(regexArray[1], regexArray[2]).test(args[0]); } else { return false; } } case 'strStartsWith': return args[0].startsWith(args[1]); default: return false; } } static evaluateNumberFunction(func, feature) { if (func.name === 'pi') { return Math.PI; } if (func.name === 'random') { return Math.random(); } const args = func.args.map(arg => { if (isGeoStylerFunction(arg)) { return OlStyleUtil.evaluateFunction(arg, feature); } return arg; }); switch (func.name) { case 'abs': return Math.abs(args[0]); case 'acos': return Math.acos(args[0]); case 'add': return args[0] + args[1]; case 'asin': return Math.asin(args[0]); case 'atan': return Math.atan(args[0]); case 'atan2': // TODO: evaluate this correctly return args[0]; case 'ceil': return Math.ceil(args[0]); case 'cos': return Math.cos(args[0]); case 'div': return args[0] / args[1]; case 'exp': return Math.exp(args[0]); case 'floor': return Math.floor(args[0]); case 'log': return Math.log(args[0]); case 'max': return Math.max(...args); case 'min': return Math.min(...args); case 'modulo': return args[0] % args[1]; case 'mul': return args[0] * args[1]; case 'pow': return Math.pow(args[0], args[1]); case 'rint': // TODO: evaluate this correctly return args[0]; case 'round': return Math.round(args[0]); case 'sin': return Math.sin(args[0]); case 'sqrt': return Math.sqrt(args[0]); case 'strIndexOf': return args[0].indexOf(args[1]); case 'strLastIndexOf': return args[0].lastIndexOf(args[1]); case 'strLength': return args[0].length; case 'sub': return args[0] - args[1]; case 'tan': return Math.tan(args[0]); case 'toDegrees': return args[0] * (180 / Math.PI); case 'toRadians': return args[0] * (Math.PI / 180); default: return args[0]; } } static evaluateUnknownFunction(func, feature) { const args = func.args.map(arg => { if (isGeoStylerFunction(arg)) { return OlStyleUtil.evaluateFunction(arg, feature); } return arg; }); switch (func.name) { case 'property': return feature?.get(args[0]); case 'case': { const caseArgs = args; let match; for (let index = 0; index < caseArgs.length; index++) { const caseArg = caseArgs[index]; // last arg of the case-function-expression is the default value if (index === caseArgs.length - 1) { match = caseArg; break; // the case can be a boolean } else if (caseArg.case === true) { match = caseArg.value; break; // … or a boolean function that has to be evaluated first } else if (OlStyleUtil.evaluateBooleanFunction(caseArg.case, feature)) { match = caseArg.value; break; } } return match; } default: return args[0]; } } static evaluateStringFunction(func, feature) { const args = func.args.map(arg => { if (isGeoStylerFunction(arg)) { return OlStyleUtil.evaluateFunction(arg, feature); } return arg; }); switch (func.name) { case 'numberFormat': // TODO: evaluate this correctly return args[0]; case 'strAbbreviate': // TODO: evaluate this correctly return args[0]; case 'strCapitalize': { // https://stackoverflow.com/a/32589289/10342669 const splitStr = args[0].toLowerCase().split(' '); for (let part of splitStr) { part = part.charAt(0).toUpperCase() + part.substring(1); } return splitStr.join(' '); } case 'strConcat': return args.join(); case 'strDefaultIfBlank': return args[0]?.length < 1 ? args[1] : args[0]; case 'strReplace': if (args[3] === true) { return args[0].replaceAll(args[1], args[2]); } else { return args[0].replace(args[1], args[2]); } case 'strStripAccents': // https://stackoverflow.com/a/37511463/10342669 return args[0].normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); case 'strSubstring': return args[0].substring(args[1], args[2]); case 'strSubstringStart': return args[0].substring(args[1]); case 'strToLowerCase': return args[0].toLowerCase(); case 'strToUpperCase': return args[0].toUpperCase(); case 'strTrim': return args[0].trim(); default: return args[0]; } } static containsGeoStylerFunctions(style) { return style.rules.some(rule => { const filterHasFunction = Array.isArray(rule.filter) && rule.filter?.some(isGeoStylerFunction); const styleHasFunction = rule.symbolizers?.some(symbolizer => { return Object.values(symbolizer).some(isGeoStylerFunction); }); const scaleDenominatorHasFunction = isGeoStylerFunction(rule?.scaleDenominator?.max) || isGeoStylerFunction(rule?.scaleDenominator?.min); return filterHasFunction || styleHasFunction || scaleDenominatorHasFunction; }); } } export default OlStyleUtil; //# sourceMappingURL=OlStyleUtil.js.map