symbology.js

/* jshint maxcomplexity: false */
'use strict';
const svgjs = require('svg.js');
const shared = require('./shared.js')();

// Functions for turning ESRI Renderers into images
// Specifically, converting ESRI "Simple" symbols into images,
// and deriving the appropriate image for a feature based on
// a renderer

// layer symbology types
const SIMPLE = 'simple';
const UNIQUE_VALUE = 'uniqueValue';
const CLASS_BREAKS = 'classBreaks';

const CONTAINER_SIZE = 32; // size of the symbology item container
const CONTENT_SIZE = 24; // size of the symbology graphic
const CONTENT_IMAGE_SIZE = 28; // size of the symbology graphic if it's an image (images tend to already have a white boarder around them)
const CONTAINER_CENTER = CONTAINER_SIZE / 2;
const CONTENT_PADDING = (CONTAINER_SIZE - CONTENT_SIZE) / 2;

/**
 * Will add extra properties to a renderer to support filtering by symbol.
 * New property .definitionClause contains sql where fragment valid for symbol
 * for app on each renderer item.
 *
 * @param {Object} renderer an ESRI renderer object in server JSON form. Param is modified in place
 * @param  {Array} fields Optional. Array of field definitions for the layer the renderer belongs to. If missing, all fields are assumed as String
 */
function filterifyRenderer(renderer, fields) {

    // worker function. determines if a field value should be wrapped in
    // any character and returns the character. E.g. string would return ', numbers return empty string.
    const getFieldDelimiter = fieldName => {

        let delim = `'`;

        // no field definition means we assume strings.
        if (!fields || fields.length === 0) {
            return delim;
        }

        // attempt to find our field, and a data type for it.
        const f = fields.find(ff => ff.name === fieldName);
        if (f && f.type && f.type !== 'esriFieldTypeString') {
            // we found a field, with a type on it, but it's not a string. remove the delimiters
            delim = '';
        }

        return delim;
    };

    // worker function to turn single quotes in a value into two
    // single quotes to avoid conflicts with the text delimiters
    const quoter = inStr => {
        return inStr.replace(/'/g, "''");
    };

    switch (renderer.type) {
        case SIMPLE:
            renderer.definitionClause = '1=1';
            break;

        case UNIQUE_VALUE:
            if (renderer.bypassDefinitionClause) {
                // we are working with a renderer that we generated from a server legend.
                // just set dumb basic things.
                renderer.uniqueValueInfos.forEach(uvi => {
                    uvi.definitionClause = '1=1';
                });
            } else {
                const delim = renderer.fieldDelimiter || ', ';
                const keyFields = ['field1', 'field2', 'field3']
                    .map(fn => renderer[fn]) // extract field names
                    .filter(fn => fn);       // remove any undefined names

                const fieldDelims = keyFields.map(fn => getFieldDelimiter(fn));

                renderer.uniqueValueInfos.forEach(uvi => {
                    // unpack .value into array
                    const keyValues = uvi.value.split(delim);

                    // convert fields/values into sql clause
                    const clause = keyFields
                        .map((kf, i) =>  `${kf} = ${fieldDelims[i]}${quoter(keyValues[i])}${fieldDelims[i]}`)
                        .join(' AND ');

                    uvi.definitionClause = `(${clause})`;
                });
            }

            break;
        case CLASS_BREAKS:

            const f = renderer.field;
            let lastMinimum = renderer.minValue;

            // figure out ranges of each break.
            // minimum is optional, so we have to keep track of the previous max as fallback
            renderer.classBreakInfos.forEach(cbi => {
                const minval = isNaN(cbi.classMinValue) ? lastMinimum : cbi.classMinValue;
                cbi.definitionClause = `(${f} > ${minval} AND ${f} <= ${cbi.classMaxValue})`;
                lastMinimum = cbi.classMaxValue;
            });

            break;
        default:

            // Renderer we dont support
            console.warn('encountered unsupported renderer type: ' + renderer.type);
    }
}

/**
 * Will add extra properties to a renderer to support images.
 * New properties .svgcode and .defaultsvgcode contains image source
 * for app on each renderer item.
 *
 * @param {Object} renderer an ESRI renderer object in server JSON form. Param is modified in place
 * @param {Object} legend object for the layer that maps legend label to data url of legend image
 * @return {Promise} resolving when the renderer has been enhanced
 */
function enhanceRenderer(renderer, legend) {

    // TODO note somewhere (user docs) that everything fails if someone publishes a legend with two identical labels.
    // UPDATE turns out services like this exist, somewhat. While the legend has unique labels, the renderer
    //        can have multiple items with the same corresponding label.  Things still hang together in that case,
    //        since we still have a 1-to-1 relationship between label and icon (all multiples in renderer have
    //        same label)

    // quick lookup object of legend names to data URLs.
    // our legend object is in ESRI format, but was generated by us and only has info for a single layer.
    // so we just grab item 0, which is the only item.
    const legendLookup = {};

    // store svgcode in the lookup
    const legendItemPromises = legend.layers[0].legend.map(legItem =>
        legItem.then(data =>
            legendLookup[data.label] = data.svgcode
        ));

    // wait until all legend items are resolved and legend lookup is updated
    return Promise.all(legendItemPromises).then(() => {
        switch (renderer.type) {
            case SIMPLE:
                renderer.svgcode = legendLookup[renderer.label];
                break;

            case UNIQUE_VALUE:
                if (renderer.defaultLabel) {
                    renderer.defaultsvgcode = legendLookup[renderer.defaultLabel];
                }

                renderer.uniqueValueInfos.forEach(uvi => {
                    uvi.svgcode = legendLookup[uvi.label];
                });

                break;
            case CLASS_BREAKS:
                if (renderer.defaultLabel) {
                    renderer.defaultsvgcode = legendLookup[renderer.defaultLabel];
                }

                renderer.classBreakInfos.forEach(cbi => {
                    cbi.svgcode = legendLookup[cbi.label];
                });

                break;
            default:

                // Renderer we dont support
                console.warn('encountered unsupported renderer type: ' + renderer.type);
        }
    });
}

/**
 * Will inspect the field names in a renderer and adjust any mis-matched casing
 * to align with the layer field definitions
 *
 * @private
 * @param  {Object} renderer a layer renderer in json format
 * @param  {Array} fields   list of field objects for the layer
 * @returns {Object} the renderer with any fields adjusted with proper case
 */
function cleanRenderer(renderer, fields) {

    const enhanceField = (fieldName, fields) => {
        if (!fieldName) {
            // testing an undefined/unused field. return original value.
            return fieldName;
        }
        let myField = fields.find(f => f.name === fieldName);
        if (myField) {
            // field is valid. donethanks.
            return fieldName;
        } else {
            // do case-insensitive search
            const lowName = fieldName.toLowerCase();
            myField = fields.find(f => f.name.toLowerCase() === lowName);
            if (myField) {
                // use the field definition casing
                return myField.name;
            } else {
                // decided error here was too destructive. it would tank the layer,
                // while the drawback would mainly only be failed symbols.
                // just return fieldName and hope for luck.
                console.warn(`could not find renderer field ${fieldName}`);
                return fieldName;
            }
        }
    };

    switch (renderer.type) {
        case SIMPLE:
            break;
        case UNIQUE_VALUE:
            ['field1', 'field2', 'field3'].forEach(f => {
                // call ehnace case for each field
                renderer[f] = enhanceField(renderer[f], fields);
            });
            break;
        case CLASS_BREAKS:
            renderer.field = enhanceField(renderer.field, fields);
            break;
        default:
            // Renderer we dont support
            console.warn('encountered unsupported renderer type: ' + renderer.type);
    }
    return renderer;
}

/**
 * Given feature attributes, find the renderer node that would draw it
 *
 * @method searchRenderer
 * @param {Object} attributes object of feature attribute key value pairs
 * @param {Object} renderer an enhanced renderer (see function enhanceRenderer)
 * @return {Object} an Object with svgcode and symbol properties for the matched renderer item
 */
function searchRenderer(attributes, renderer) {

    let svgcode;
    let symbol = {};

    switch (renderer.type) {
        case SIMPLE:
            svgcode = renderer.svgcode;
            symbol = renderer.symbol;

            break;

        case UNIQUE_VALUE:

            // make a key value for the graphic in question, using comma-space delimiter if multiple fields
            // put an empty string when key value is null
            let graphicKey = attributes[renderer.field1] === null ? '' : attributes[renderer.field1];

            // all key values are stored as strings.  if the attribute is in a numeric column, we must convert it to a string to ensure the === operator still works.
            if (typeof graphicKey !== 'string') {
                graphicKey = graphicKey.toString();
            }

            // TODO investigate possibility of problems due to falsey logic.
            //      e.g. if we had a field2 with empty string, would app expect
            //           'value1, ' or 'value1'
            //      need to brew up some samples to see what is possible in ArcMap
            if (renderer.field2) {
                const delim = renderer.fieldDelimiter || ', ';
                graphicKey = graphicKey + delim + attributes[renderer.field2];
                if (renderer.field3) {
                    graphicKey = graphicKey + delim + attributes[renderer.field3];
                }
            }

            // search the value maps for a matching entry.  if no match found, use the default image
            const uvi = renderer.uniqueValueInfos.find(uvi => uvi.value === graphicKey);
            if (uvi) {
                svgcode = uvi.svgcode;
                symbol = uvi.symbol;
            } else {
                svgcode = renderer.defaultsvgcode;
                symbol = renderer.defaultSymbol;
            }

            break;

        case CLASS_BREAKS:

            const gVal = parseFloat(attributes[renderer.field]);
            const lower = renderer.minValue;

            svgcode = renderer.defaultsvgcode;
            symbol = renderer.defaultSymbol;

            // check for outside range on the low end
            if (gVal < lower) { break; }

            // array of minimum values of the ranges in the renderer
            let minSplits = renderer.classBreakInfos.map(cbi => cbi.classMaxValue);
            minSplits.splice(0, 0, lower - 1); // put lower-1 at the start of the array and shift all other entries by 1

            // attempt to find the range our gVal belongs in
            const cbi = renderer.classBreakInfos.find((cbi, index) => gVal > minSplits[index] &&
                gVal <= cbi.classMaxValue);
            if (!cbi) { // outside of range on the high end
                break;
            }
            svgcode = cbi.svgcode;
            symbol = cbi.symbol;

            break;

        default:
            console.warn(`Unknown renderer type encountered - ${renderer.type}`);

    }

    // make an empty svg graphic in case nothing is found to avoid undefined inside the filters
    if (typeof svgcode === 'undefined') {
        svgcode = svgjs(window.document.createElement('div')).size(CONTAINER_SIZE, CONTAINER_SIZE).svg();
    }

    return { svgcode, symbol };

}

/**
 * Given feature attributes, return the image URL for that feature/graphic object.
 *
 * @method getGraphicIcon
 * @param {Object} attributes object of feature attribute key value pairs
 * @param {Object} renderer an enhanced renderer (see function enhanceRenderer)
 * @return {String} svgcode Url to the features symbology image
 */
function getGraphicIcon(attributes, renderer) {
    const renderInfo = searchRenderer(attributes, renderer);
    return renderInfo.svgcode;
}

/**
 * Given feature attributes, return the symbol for that feature/graphic object.
 *
 * @method getGraphicSymbol
 * @param {Object} attributes object of feature attribute key value pairs
 * @param {Object} renderer an enhanced renderer (see function enhanceRenderer)
 * @return {Object} an ESRI Symbol object in server format
 */
function getGraphicSymbol(attributes, renderer) {
    const renderInfo = searchRenderer(attributes, renderer);
    return renderInfo.symbol;
}

/**
 * Generates svg symbology for WMS layers.
 * @function generateWMSSymbology
 * @param {String} name label for the symbology item (it's not used right now, but is required to be consistent with other symbology generating functions)
 * @param {String} imageUri url or dataUrl of the legend image
 * @return {Promise} a promise resolving with symbology svg code and its label
 */
function generateWMSSymbology(name, imageUri) {
    const draw = svgjs(window.document.createElement('div'))
        .size(CONTAINER_SIZE, CONTAINER_SIZE)
        .viewbox(0, 0, 0, 0);

    const symbologyItem = {
        name,
        svgcode: null
    };

    if (imageUri) {
        const renderPromise = renderSymbologyImage(imageUri).then(svgcode => {
            symbologyItem.svgcode = svgcode;

            return symbologyItem;
        });

        return renderPromise;
    } else {
        symbologyItem.svgcode = draw.svg();

        return Promise.resolve(symbologyItem);
    }
}

/**
 * Converts a config-supplied list of symbology to the format used by layer records.
 *
 * @private
 * @function _listToSymbology
 * @param {Function} conversionFunction a conversion function to wrap the supplied image into an image or an icon style symbology container
 * @param {Array} list a list of config-supplied symbology items in the form of [ { text: <String>, image: <String> }, ... ] wher `image` can be dataURL or an actual url
 * @return {Array} an array of converted symbology symbols in the form of [ { name: <String>, image: <String>, svgcode: <String> }, ... ]; items will be populated async as conversions are done
 */
function _listToSymbology(conversionFunction, list) {
    const results = list.map(({ text, image }) => {
        const result = {
            name: text,
            image, // url
            svgcode: null
        };

        conversionFunction(image).then(svgcode => {
            result.svgcode = svgcode;
        });

        return result;
    });

    return results;
}

/**
 * Renders a supplied image as an image-style symbology item (preserving the true image dimensions).
 *
 * @function renderSymbologyImage
 * @param {String} imageUri a image dataUrl or a regular url
 * @param {Object} draw [optional=null] an svg container to draw the image on; if not supplied, a new one is created
 */
function renderSymbologyImage(imageUri, draw = null) {
    if (draw === null) {
        draw = svgjs(window.document.createElement('div'))
            .size(CONTAINER_SIZE, CONTAINER_SIZE)
            .viewbox(0, 0, 0, 0);
    }

    const symbologyPromise = shared.convertImagetoDataURL(imageUri)
        .then(imageUri =>
            svgDrawImage(draw, imageUri))
        .then(({ loader }) => {
            draw.viewbox(0, 0, loader.width, loader.height);
            return draw.svg();
        })
        .catch(err => {
            console.error('Cannot draw symbology iamge; returning empty', err);
            return draw.svg();
        });

    return symbologyPromise;
}

/**
 * Renders a supplied image as an icon-style symbology item (fitting an image inside an icon container, usually 32x32 pixels).
 *
 * @function renderSymbologyIcon
 * @param {String} imageUri a image dataUrl or a regular url
 * @param {Object} draw [optional=null] an svg container to draw the image on; if not supplied, a new one is created
 */
function renderSymbologyIcon(imageUri, draw = null) {
    if (draw === null) {
        // create a temporary svg element and add it to the page; if not added, the element's bounding box cannot be calculated correctly
        const container = window.document.createElement('div');
        container.setAttribute('style', 'opacity:0;position:fixed;left:100%;top:100%;overflow:hidden');
        window.document.body.appendChild(container);

        draw = svgjs(container)
            .size(CONTAINER_SIZE, CONTAINER_SIZE)
            .viewbox(0, 0, CONTAINER_SIZE, CONTAINER_SIZE);
    }

    // need to draw the image to get its size (technically not needed if we have a url, but this is simpler)
    const picturePromise = shared.convertImagetoDataURL(imageUri)
        .then(imageUri =>
            svgDrawImage(draw, imageUri))
        .then(({ image }) => {
            image.center(CONTAINER_CENTER, CONTAINER_CENTER);

            // scale image to fit into the symbology item container
            fitInto(image, CONTENT_IMAGE_SIZE);

            return draw.svg();
        });

    return picturePromise;
}

/**
 * Generates a placeholder symbology graphic. Returns a promise for consistency
 * @function generatePlaceholderSymbology
 * @private
 * @param  {String} name label symbology label
 * @param  {String} colour colour to use in the graphic
 * @return {Object} symbology svg code and its label
 */
function generatePlaceholderSymbology(name, colour = '#000') {
    const draw = svgjs(window.document.createElement('div'))
        .size(CONTAINER_SIZE, CONTAINER_SIZE)
        .viewbox(0, 0, CONTAINER_SIZE, CONTAINER_SIZE);

    draw.rect(CONTENT_IMAGE_SIZE, CONTENT_IMAGE_SIZE)
        .center(CONTAINER_CENTER, CONTAINER_CENTER)
        .fill(colour);

    draw
        .text(name[0].toUpperCase()) // take the first letter
        .size(23)
        .fill('#fff')
        .attr({
            'font-weight': 'bold',
            'font-family': 'Roboto'
        })
        .center(CONTAINER_CENTER, CONTAINER_CENTER);

    return {
        name,
        svgcode: draw.svg()
    };
}

/**
 * Generate a legend item for an ESRI symbol.
 * @private
 * @param  {Object} symbol an ESRI symbol object in server format
 * @param  {String} label label of the legend item
 * @param  {String} definitionClause sql clause to filter on this legend item
 * @param  {Object} window reference to the browser window
 * @return {Object} a legend object populated with the symbol and label
 */
function symbolToLegend(symbol, label, definitionClause, window) {
    // create a temporary svg element and add it to the page; if not added, the element's bounding box cannot be calculated correctly
    const container = window.document.createElement('div');
    container.setAttribute('style', 'opacity:0;position:fixed;left:100%;top:100%;overflow:hidden');
    window.document.body.appendChild(container);

    const draw = svgjs(container)
        .size(CONTAINER_SIZE, CONTAINER_SIZE)
        .viewbox(0, 0, CONTAINER_SIZE, CONTAINER_SIZE);

    // functions to draw esri simple marker symbols
    // jscs doesn't like enhanced object notation
    // jscs:disable requireSpacesInAnonymousFunctionExpression
    const esriSimpleMarkerSimbol = {
        esriSMSPath({ size, path }) {
            return draw.path(path).size(size);
        },
        esriSMSCircle({ size }) {
            return draw.circle(size);
        },
        esriSMSCross({ size }) {
            return draw.path('M 0,10 L 20,10 M 10,0 L 10,20').size(size);
        },
        esriSMSX({ size }) {
            return draw.path('M 0,0 L 20,20 M 20,0 L 0,20').size(size);
        },
        esriSMSTriangle({ size }) {
            return draw.path('M 20,20 L 10,0 0,20 Z').size(size);
        },
        esriSMSDiamond({ size }) {
            return draw.path('M 20,10 L 10,0 0,10 10,20 Z').size(size);
        },
        esriSMSSquare({ size }) {
            return draw.path('M 0,0 20,0 20,20 0,20 Z').size(size);
        }
    };

    // jscs:enable requireSpacesInAnonymousFunctionExpression

    // line dash styles
    const ESRI_DASH_MAPS = {
        esriSLSSolid: 'none',
        esriSLSDash: '5.333,4',
        esriSLSDashDot: '5.333,4,1.333,4',
        esriSLSLongDashDotDot: '10.666,4,1.333,4,1.333,4',
        esriSLSDot: '1.333,4',
        esriSLSLongDash: '10.666,4',
        esriSLSLongDashDot: '10.666,4,1.333,4',
        esriSLSShortDash: '5.333,1.333',
        esriSLSShortDashDot: '5.333,1.333,1.333,1.333',
        esriSLSShortDashDotDot: '5.333,1.333,1.333,1.333,1.333,1.333',
        esriSLSShortDot: '1.333,1.333',
        esriSLSNull: 'none'
    };

    // default stroke style
    const DEFAULT_STROKE = {
        color: '#000',
        opacity: 1,
        width: 1,
        linecap: 'square',
        linejoin: 'miter',
        miterlimit: 4
    };

    // this is a null outline in case a supplied symbol doesn't have one
    const DEFAULT_OUTLINE = {
        color: [0, 0, 0, 0],
        width: 0,
        style: ESRI_DASH_MAPS.esriSLSNull
    };

    // 5x5 px patter with coloured diagonal lines
    const esriSFSFills = {
        esriSFSSolid: symbolColour => {
            return {
                color: symbolColour.colour,
                opacity: symbolColour.opacity
            };
        },
        esriSFSNull: () => 'transparent',
        esriSFSHorizontal: (symbolColour, symbolStroke) => {
            const cellSize = 5;

            // patter fill: horizonal line in a 5x5 px square
            return draw.pattern(cellSize, cellSize, add =>
                add.line(0, cellSize / 2, cellSize, cellSize / 2)).stroke(symbolStroke);
        },
        esriSFSVertical: (symbolColour, symbolStroke) => {
            const cellSize = 5;

            // patter fill: vertical line in a 5x5 px square
            return draw.pattern(cellSize, cellSize, add =>
                add.line(cellSize / 2, 0, cellSize / 2, cellSize)).stroke(symbolStroke);
        },
        esriSFSForwardDiagonal: (symbolColour, symbolStroke) => {
            const cellSize = 5;

            // patter fill: forward diagonal line in a 5x5 px square; two more diagonal lines offset to cover the corners when the main line is cut off
            return draw.pattern(cellSize, cellSize, add => {
                add.line(0, 0, cellSize, cellSize).stroke(symbolStroke);
                add.line(0, 0, cellSize, cellSize).move(0, cellSize).stroke(symbolStroke);
                add.line(0, 0, cellSize, cellSize).move(cellSize, 0).stroke(symbolStroke);
            });
        },
        esriSFSBackwardDiagonal: (symbolColour, symbolStroke) => {
            const cellSize = 5;

            // patter fill: backward diagonal line in a 5x5 px square; two more diagonal lines offset to cover the corners when the main line is cut off
            return draw.pattern(cellSize, cellSize, add => {
                add.line(cellSize, 0, 0, cellSize).stroke(symbolStroke);
                add.line(cellSize, 0, 0, cellSize).move(cellSize / 2, cellSize / 2).stroke(symbolStroke);
                add.line(cellSize, 0, 0, cellSize).move(-cellSize / 2, -cellSize / 2).stroke(symbolStroke);
            });
        },
        esriSFSCross: (symbolColour, symbolStroke) => {
            const cellSize = 5;

            // patter fill: horizonal and vertical lines in a 5x5 px square
            return draw.pattern(cellSize, cellSize, add => {
                add.line(cellSize / 2, 0, cellSize / 2, cellSize).stroke(symbolStroke);
                add.line(0, cellSize / 2, cellSize, cellSize / 2).stroke(symbolStroke);
            });
        },
        esriSFSDiagonalCross: (symbolColour, symbolStroke) => {
            const cellSize = 7;

            // patter fill: crossing diagonal lines in a 7x7 px square
            return draw.pattern(cellSize, cellSize, add => {
                add.line(0, 0, cellSize, cellSize).stroke(symbolStroke);
                add.line(cellSize, 0, 0, cellSize).stroke(symbolStroke);
            });
        }
    };

    // jscs doesn't like enhanced object notation
    // jscs:disable requireSpacesInAnonymousFunctionExpression
    const symbolTypes = {
        esriSMS() { // ESRI Simple Marker Symbol
            const symbolColour = parseEsriColour(symbol.color);

            symbol.outline = symbol.outline || DEFAULT_OUTLINE;
            const outlineColour = parseEsriColour(symbol.outline.color);
            const outlineStroke = makeStroke({
                color: outlineColour.colour,
                opacity: outlineColour.opacity,
                width: symbol.outline.width,
                dasharray: ESRI_DASH_MAPS[symbol.outline.style]
            });

            // make an ESRI simple symbol and apply fill and outline to it
            const marker = esriSimpleMarkerSimbol[symbol.style](symbol)
                .fill({
                    color: symbolColour.colour,
                    opacity: symbolColour.opacity
                })
                .stroke(outlineStroke)
                .center(CONTAINER_CENTER, CONTAINER_CENTER)
                .rotate(symbol.angle || 0);

            fitInto(marker, CONTENT_SIZE);
        },
        esriSLS() { // ESRI Simple Line Symbol
            const lineColour = parseEsriColour(symbol.color);
            const lineStroke = makeStroke({
                color: lineColour.colour,
                opacity: lineColour.opacity,
                width: symbol.width,
                linecap: 'butt',
                dasharray: ESRI_DASH_MAPS[symbol.style]
            });

            const min = CONTENT_PADDING;
            const max = CONTAINER_SIZE - CONTENT_PADDING;
            draw.line(min, min, max, max)
                .stroke(lineStroke);
        },
        esriCLS() {  // ESRI Fancy Line Symbol
            this.esriSLS();
        },
        esriSFS() { // ESRI Simple Fill Symbol
            const symbolColour = parseEsriColour(symbol.color);
            const symbolStroke = makeStroke({
                color: symbolColour.colour,
                opacity: symbolColour.opacity
            });
            const symbolFill = esriSFSFills[symbol.style](symbolColour, symbolStroke);

            symbol.outline = symbol.outline || DEFAULT_OUTLINE;
            const outlineColour = parseEsriColour(symbol.outline.color);
            const outlineStroke = makeStroke({
                color: outlineColour.colour,
                opacity: outlineColour.opacity,
                width: symbol.outline.width,
                linecap: 'butt',
                dasharray: ESRI_DASH_MAPS[symbol.outline.style]
            });

            draw.rect(CONTENT_SIZE, CONTENT_SIZE)
                .center(CONTAINER_CENTER, CONTAINER_CENTER)
                .fill(symbolFill)
                .stroke(outlineStroke);
        },

        esriTS() {
            console.error('no support for feature service legend of text symbols');
        },

        esriPFS() { // ESRI Picture Fill Symbol
            // imageUri can be just an image url is specified or a dataUri string
            const imageUri = symbol.imageData ? `data:${symbol.contentType};base64,${symbol.imageData}` : symbol.url;

            const imageWidth = symbol.width * symbol.xscale;
            const imageHeight = symbol.height * symbol.yscale;

            symbol.outline = symbol.outline || DEFAULT_OUTLINE;
            const outlineColour = parseEsriColour(symbol.outline.color);
            const outlineStroke = makeStroke({
                color: outlineColour.colour,
                opacity: outlineColour.opacity,
                width: symbol.outline.width,
                dasharray: ESRI_DASH_MAPS[symbol.outline.style]
            });

            const picturePromise = shared.convertImagetoDataURL(imageUri)
                .then(imageUri => {
                    // make a fill from a tiled image
                    const symbolFill = draw.pattern(imageWidth, imageHeight, add =>
                        add.image(imageUri, imageWidth, imageHeight, true));

                    draw.rect(CONTENT_SIZE, CONTENT_SIZE)
                        .center(CONTAINER_CENTER, CONTAINER_CENTER)
                        .fill(symbolFill)
                        .stroke(outlineStroke);
                });

            return picturePromise;
        },

        esriPMS() { // ESRI PMS? Picture Marker Symbol
            // imageUri can be just an image url is specified or a dataUri string
            const imageUri = symbol.imageData ? `data:${symbol.contentType};base64,${symbol.imageData}` : symbol.url;

            // need to draw the image to get its size (technically not needed if we have a url, but this is simpler)
            const picturePromise = shared.convertImagetoDataURL(imageUri)
                .then(imageUri =>
                    svgDrawImage(draw, imageUri))
                .then(({ image }) => {
                    image
                        .center(CONTAINER_CENTER, CONTAINER_CENTER)
                        .rotate(symbol.angle || 0);

                    // scale image to fit into the symbology item container
                    fitInto(image, CONTENT_IMAGE_SIZE);
                });

            return picturePromise;
        }
    };

    // jscs:enable requireSpacesInAnonymousFunctionExpression

    // console.log(symbol.type, label, '--START--');
    // console.log(symbol);

    return Promise.resolve(symbolTypes[symbol.type]())
        .then(() => {
            // console.log(symbol.type, label, '--DONE--');

            // remove element from the page
            window.document.body.removeChild(container);
            return { label, definitionClause, svgcode: draw.svg() };
        }).catch(error => console.log(error));

    /**
     * Creates a stroke style by applying custom rules to the default stroke.
     * @param {Object} overrides any custom rules to apply on top of the defaults
     * @return {Object} a stroke object
     * @private
     */
    function makeStroke(overrides) {
        return Object.assign({}, DEFAULT_STROKE, overrides);
    }

    /**
     * Convert an ESRI colour object to SVG rgb format.
     * @private
     * @param  {Array} c ESRI Colour array
     * @return {Object} colour and opacity in SVG format
     */
    function parseEsriColour(c) {
        if (c) {
            return {
                colour: `rgb(${c[0]},${c[1]},${c[2]})`,
                opacity: c[3] / 255
            };
        } else {
            return {
                colour: 'rgb(0, 0, 0)',
                opacity: 0
            };
        }
    }
}

/**
 * Renders a specified image on an svg element. This is a helper function that wraps around async `draw.image` call in the svg library.
 *
 * @function svgDrawImage
 * @private
 * @param {Object} draw svg element to render the image onto
 * @param {String} imageUri image url or dataURL of the image to render
 * @param {Number} width [optional = 0] width of the image
 * @param {Number} height [optional = 0] height of the image
 * @param {Boolean} crossOrigin [optional = true] specifies if the image should be loaded as crossOrigin
 * @return {Promise} promise resolving with the loaded image and its loader object (see svg.js http://documentup.com/wout/svg.js#image for details)
 */
function svgDrawImage(draw, imageUri, width = 0, height = 0, crossOrigin = true) {
    const promise = new Promise((resolve, reject) => {
        const image = draw.image(imageUri, width, height, crossOrigin)
            .loaded(loader =>
                resolve({ image, loader }))
            .error(err => {
                reject(err);
                console.error(err);
            });
    });

    return promise;
}

/**
 * Fits svg element in the size specified
 * @param {Ojbect} element svg element to fit
 * @param {Number} CONTAINER_SIZE width/height of a container to fit the element into
 */
function fitInto(element, CONTAINER_SIZE) {
    // const elementRbox = element.rbox();
    // const elementRbox = element.screenBBox();

    const elementRbox = element.node.getBoundingClientRect(); // marker.rbox(); //rbox doesn't work properly in Chrome for some reason
    const scale = CONTAINER_SIZE / Math.max(elementRbox.width, elementRbox.height);
    if (scale < 1) {
        element.scale(scale);
    }
}

/**
 * Generate an array of legend items for an ESRI unique value or class breaks renderer.
 * @private
 * @param  {Object} renderer an ESRI unique value or class breaks renderer
 * @param  {Array} childList array of children items of the renderer
 * @param  {Object} window reference to the browser window
 * @return {Array} a legend object populated with the symbol and label
 */
function scrapeListRenderer(renderer, childList, window) {

    // a renderer list can have multiple entries for the same label
    // (e.g. mapping two unique values to the same legend category).
    // here we assume an identical labels equate to a single legend
    // entry.

    const preLegend = childList.map(child => {
        return { symbol: child.symbol, label: child.label,
            definitionClause: child.definitionClause };
    });

    if (renderer.defaultSymbol) {
        // calculate fancy sql clause to select "everything else"
        const elseClauseGuts = preLegend
            .map(pl => pl.definitionClause)
            .join(' OR ');

        const elseClause = `(NOT (${elseClauseGuts}))`;

        // class breaks dont have default label
        // TODO perhaps put in a default of "Other", would need to be in proper language
        preLegend.push({
            symbol: renderer.defaultSymbol,
            definitionClause: elseClause,
            label: renderer.defaultLabel || ''
        });
    }

    // filter out duplicate lables, then convert remaining things to legend items
    return preLegend
        .filter((item, index, inputArray) => {
            const firstFindIdx = inputArray.findIndex(dupItem => {
                return item.label === dupItem.label;
            });

            if (index === firstFindIdx) {
                // first time encountering the label. done thanks
                return true;
            } else {
                // not first time encountering the label.
                // drop from legend, but tack definition clause onto first one
                const firstItem = inputArray[firstFindIdx];
                firstItem.isCompound = true;
                firstItem.definitionClause += ` OR ${item.definitionClause}`;
                return false;
            }

        })
        .map(item => {
            if (item.isCompound) {
                item.definitionClause = `(${item.definitionClause})`; // wrap compound expression in brackets
            }
            return symbolToLegend(item.symbol, item.label, item.definitionClause, window);
        });
}

function buildRendererToLegend(window) {
    /**
     * Generate a legend object based on an ESRI renderer.
     * @private
     * @param  {Object} renderer an ESRI renderer object in server JSON form
     * @param  {Integer} index the layer index of this renderer
     * @param  {Array} fields Optional. Array of field definitions for the layer the renderer belongs to. If missing, all fields are assumed as String
     * @return {Object} an object matching the form of an ESRI REST API legend
     */
    return (renderer, index, fields) => {
        // SVG Legend symbology uses pixels instead of points from ArcGIS Server, thus we need
        // to multply it by a factor to correct the values.  96 DPI from ArcGIS Server is assumed.
        const ptFactor = 1.33333; // points to pixel factor

        // make basic shell object with .layers array
        const legend = {
            layers: [{
                layerId: index,
                legend: []
            }]
        };

        // calculate symbology filter logic
        filterifyRenderer(renderer, fields);

        switch (renderer.type) {
            case SIMPLE:
                renderer.symbol.size = Math.round(renderer.symbol.size * ptFactor);
                legend.layers[0].legend.push(symbolToLegend(renderer.symbol,
                    renderer.label, renderer.definitionClause, window));
                break;

            case UNIQUE_VALUE:
                if (renderer.defaultSymbol) {
                    renderer.defaultSymbol.size = Math.round(renderer.defaultSymbol.size * ptFactor);
                }
                renderer.uniqueValueInfos.forEach(val => {
                    val.symbol.size = Math.round(val.symbol.size * ptFactor);
                });
                legend.layers[0].legend = scrapeListRenderer(renderer, renderer.uniqueValueInfos, window);
                break;

            case CLASS_BREAKS:
                if (renderer.defaultSymbol) {
                    renderer.defaultSymbol.size = Math.round(renderer.defaultSymbol.size * ptFactor);
                }
                renderer.classBreakInfos.forEach(val => {
                    val.symbol.size = Math.round(val.symbol.size * ptFactor);
                });
                legend.layers[0].legend = scrapeListRenderer(renderer, renderer.classBreakInfos, window);
                break;

            default:

                // FIXME make a basic blank entry (error msg as label?) to prevent things from breaking
                // Renderer we dont support
                console.error('encountered unsupported renderer legend type: ' + renderer.type);
        }
        return legend;
    };
}

/**
 * Returns the legend information of an ESRI map service.
 *
 * @function getMapServerLegend
 * @private
 * @param  {String} layerUrl service url (root service, not indexed endpoint)
 * @param  {Object} esriBundle collection of ESRI API objects
 * @returns {Promise} resolves in an array of legend data
 *
 */
function getMapServerLegend(layerUrl, esriBundle) {

    // standard json request with error checking
    const defService = esriBundle.esriRequest({
        url: `${layerUrl}/legend`,
        content: { f: 'json' },
        callbackParamName: 'callback',
        handleAs: 'json',
    });

    // wrap in promise to contain dojo deferred
    return new Promise((resolve, reject) => {
        defService.then(srvResult => {

            if (srvResult.error) {
                reject(srvResult.error);
            } else {
                resolve(srvResult);
            }
        }, error => {
            reject(error);
        });
    });

}

/**
 * Our symbology engine works off of renderers. When dealing with layers with no renderers,
 * we need to take server-side legend and convert it to a fake renderer, which lets us
 * leverage all the existing symbology code.
 *
 * @function mapServerLegendToRenderer
 * @private
 * @param {Object} serverLegend legend json from an esri map server
 * @param {Integer} layerIndex  the index of the layer in the legend we are interested in
 * @returns {Object} a fake unique value renderer based off the legend
 *
 */
function mapServerLegendToRenderer(serverLegend, layerIndex) {
    const layerLegend = serverLegend.layers.find(l => {
        return l.layerId === layerIndex;
    });

    // make the mock renderer
    return {
        type: 'uniqueValue',
        bypassDefinitionClause: true,
        uniqueValueInfos: layerLegend.legend.map(ll => {
            return {
                label: ll.label,
                symbol: {
                    type: 'esriPMS',
                    imageData: ll.imageData,
                    contentType: ll.contentType
                }
            };
        })
    };
}

/**
  * Our symbology engine works off of renderers. When dealing with layers with no renderers,
  * we need to take server-side legend and convert it to a fake renderer, which lets us
  * leverage all the existing symbology code.
  *
  * Same as mapServerLegendToRenderer function but combines all layer renderers.
  *
  * @function mapServerLegendToRendererAll
  * @private
  * @param {Object} serverLegend legend json from an esri map server
  * @returns {Object} a fake unique value renderer based off the legend
  */

function mapServerLegendToRendererAll(serverLegend) {

    const layerRenders = serverLegend.layers.map(layer =>
        layer.legend.map(layerLegend => ({
            label: layerLegend.label,
            symbol: {
                type: 'esriPMS',
                imageData: layerLegend.imageData,
                contentType: layerLegend.contentType
            }
        }))
    );

    return {
        type: 'uniqueValue',
        bypassDefinitionClause: true,
        uniqueValueInfos: [].concat(...layerRenders)
    };
}

function buildMapServerToLocalLegend(esriBundle, geoApi) {
    /**
     * Orchestrator function that will:
     * - Fetch a legend from an esri map server
     * - Extract legend for a specific sub layer
     * - Convert server legend to a temporary renderer
     * - Convert temporary renderer to a viewer-formatted legend (return value)
     *
     * @function mapServerToLocalLegend
     * @param {String}    mapServerUrl  service url (root service, not indexed endpoint)
     * @param {Integer}   [layerIndex]  the index of the layer in the legend we are interested in. If not provided, all layers will be collapsed into a single legend
     * @returns {Promise} resolves in a viewer-compatible legend for the given server and layer index
     *
     */
    return (mapServerUrl, layerIndex) => {
        // get esri legend from server

        return getMapServerLegend(mapServerUrl, esriBundle).then(serverLegendData => {
            // derive renderer for specified layer
            let fakeRenderer;
            let intIndex;
            if (typeof layerIndex === 'undefined') {
                intIndex = 0;
                fakeRenderer = mapServerLegendToRendererAll(serverLegendData);
            } else {
                intIndex = parseInt(layerIndex); // sometimes a stringified value comes in. careful now.
                fakeRenderer = mapServerLegendToRenderer(serverLegendData, intIndex);
            }

            // convert renderer to viewer specific legend
            return geoApi.symbology.rendererToLegend(fakeRenderer, intIndex);
        });
    };
}

module.exports = (esriBundle, geoApi, window) => {
    return {
        getGraphicIcon,
        getGraphicSymbol,
        rendererToLegend: buildRendererToLegend(window),
        generatePlaceholderSymbology,
        generateWMSSymbology,
        listToIconSymbology: list => _listToSymbology(renderSymbologyIcon, list),
        listToImageSymbology: list => _listToSymbology(renderSymbologyImage, list),
        enhanceRenderer,
        cleanRenderer,
        filterifyRenderer,
        mapServerToLocalLegend: buildMapServerToLocalLegend(esriBundle, geoApi)
    };
};