layer/layerRec/graphicsRecord.js

'use strict';

const layerInterface = require('./layerInterface.js')();
const root = require('./root.js')();
const shared = require('./shared.js')();

const geometryTypes = {
    POINT: 'Point',
    MULTIPOINT: 'MultiPoint',
    LINESTRING: 'LineString',
    MULTILINESTRING: 'MultiLineString',
    POLYGON: 'Polygon',
    MULTIPOLYGON: 'MultiPolygon'
}

const defaultSymbols = {
    POINT: {
        width: 16.5,
        height: 16.5,
        type: 'esriSMS',
        color: [255, 0, 0],
        outline: {
            color: [0, 0, 0],
            width: 1,
            type: 'esriSLS',
            style: 'esriSLSSolid'
        }
    },
    MULTIPOINT: {
        width: 16.5,
        height: 16.5,
        type: 'esriSMS',
        color: [0, 255, 0],
        outline: {
            color: [0, 0, 0],
            width: 1,
            type: 'esriSLS',
            style: 'esriSLSSolid'
        }
    },
    LINESTRING: {
        width: 2,
        type: 'esriSLS',
        color: [255, 0, 0],
        style:' esriSLSSolid',
        outline: {
            color: [0, 0, 0],
            width: 1,
            type: 'esriSLS',
            style: 'esriSLSSolid'
        }
    },
    MULTILINESTRING: {
        width: 2,
        type: 'esriSLS',
        color: [0, 255, 0],
        style:' esriSLSSolid',
        outline: {
            color: [0, 0, 0],
            width: 1,
            type: 'esriSLS',
            style: 'esriSLSSolid'
        }
    }
}

/**
 * @class GraphicsRecord
 */
class GraphicsRecord extends root.Root {
    /**
     * Create a graphics layer record with the appropriate geoApi layer type.
     * TODO: possibly have an intermediate class between root and layerRecord to have all the duplicated items, such as hovertips.
     * TODO: add identify functionality and fix any other features that might need to be modified
     * @param {Object} esriBundle       bundle of API classes
     * @param {Object} apiRef           object pointing to the geoApi. allows us to call other geoApi functions.
     * @param {String} name             name and id of the layer.
     */
    constructor (esriBundle, apiRef, name) {
        super();

        this._bundle = esriBundle;
        this._apiRef = apiRef;
        this._layerClass = esriBundle.GraphicsLayer;
        this._name = name;
        this._id = name;
        this._layer = this._layerClass({ id: this._name });
        this.bindEvents(this._layer);
        this._hoverListeners = [];
    }

    get layerId () { return this._id; }

    get name () { return this._name; }
    set name (value) {
        this._name = value;
        this._id = value;
    }

    get layerType () { return shared.clientLayerType.ESRI_GRAPHICS; }

    get visibility () {
        if (this._layer) {
            return this._layer.visible;
        } else {
            return true; // TODO what should a proper default be? example of this situation??
        }
    }
    set visibility (value) {
        if (this._layer) {
            this._layer.setVisibility(value);
        }

        // TODO do we need an ELSE case here?
    }

    get opacity () {
        if (this._layer) {
            return this._layer.opacity;
        } else {
            return 1; // TODO what should a proper default be? example of this situation??
        }
    }
    set opacity (value) {
        if (this._layer) {
            this._layer.setOpacity(value);
        }

        // TODO do we need an ELSE case here?
    }

    /**
     * Attach record event handlers to common layer events
     *
     * @function bindEvents
     * @param {Object} layer the api layer object
     */
    bindEvents (layer) {
        // TODO optional refactor.  Rather than making the events object in the parameter,
        //      do it as a variable, and only add mouse-over, mouse-out events if we are
        //      in an app configuration that will use it. May save a bit of processing
        //      by not having unused events being handled and ignored.
        //      Second optional thing. Call a separate wrapEvents in FeatuerRecord class
        // TODO apply johann update here
        this._apiRef.events.wrapEvents(layer, {
            // wrapping the function calls to keep `this` bound correctly
            'mouse-over': e => this.onMouseOver(e),
            'mouse-out': e => this.onMouseOut(e)
        });
    }

    /**
     * Wire up mouse hover listener.
     *
     * @function addHoverListener
     * @param {Function} listenerCallback function to call when a hover event happens
     */
    addHoverListener (listenerCallback) {
        this._hoverListeners.push(listenerCallback);
        return listenerCallback;
    }

    /**
     * Remove a mouse hover listener.
     *
     * @function removeHoverListener
     * @param {Function} listenerCallback function to not call when a hover event happens
     */
    removeHoverListener (listenerCallback) {
        const idx = this._hoverListeners.indexOf(listenerCallback);
        if (idx < 0) {
            throw new Error('Attempting to remove a listener which is not registered.');
        }
        this._hoverListeners.splice(idx, 1);
    }

    /**
     * Provides the proxy interface object to the layer.
     *
     * @function getProxy
     * @returns {Object} the proxy interface for the layer
     */
    getProxy () {
        if (!this._rootProxy) {
            this._rootProxy = new layerInterface.LayerInterface(this);
            this._rootProxy.convertToGraphicsLayer(this);
        }
        return this._rootProxy;
    }

    /**
     * Triggers when the mouse enters a feature of the layer.
     *
     * @function onMouseOver
     * @param {Object} standard mouse event object
     */
    onMouseOver (e) {
        if (this._hoverListeners.length > 0) {

            const showBundle = {
                type: 'mouseOver',
                point: e.screenPoint,
                target: e.target,
                graphic: e.graphic
            };

            // tell anyone listening we moused into something
            this._fireEvent(this._hoverListeners, showBundle);
        }
    }

    /**
     * Triggers when the mouse leaves a feature of the layer.
     *
     * @function onMouseOut
     * @param {Object} standard mouse event object
     */
    onMouseOut (e) {
        // tell anyone listening we moused out
        const outBundle = {
            type: 'mouseOut',
            target: e.target
        };
        this._fireEvent(this._hoverListeners, outBundle);
    }

    /**
     * Identify the type of geometry being added and add it to the map.
     *
     * @function addGeometry
     * @param {Object|Array} geo                  api geometry class to be added
     * @param {Object} spatialReference          the projection the graphics should be in
     */
    addGeometry(geo, spatialReference) {
        const geometries = Array.isArray(geo) ? geo : [ geo ];

        geometries.forEach(geometry => {
            const id = geometry.id;
            if (geometry.type === geometryTypes.POINT) {
                const coords = geometry.xy.projectToPoint(spatialReference);
                const icon = geometry.icon;
                this._addPoint(coords, spatialReference, icon, id);
            } else if (geometry.type === geometryTypes.MULTIPOINT) {
                const points = geometry.pointArray.map(point => {
                    const p = point.xy.projectToPoint(spatialReference);
                    return [p.x, p.y];
                });
                const icon = geometry.icon;
                this._addMultiPoint(points, spatialReference, icon, id);
            } else if (geometry.type === geometryTypes.LINESTRING) {
                const path = geometry.pointArray.map(point => {
                    const p = point.xy.projectToPoint(spatialReference);
                    return [p.x, p.y];
                });
                this._addLine(path, spatialReference, id);
            } else if (geometry.type === geometryTypes.MULTILINESTRING) {
                const path = geometry.lineArray.map(line =>
                    line.pointArray.map(point => {
                        const p = point.xy.projectToPoint(spatialReference);
                        return [p.x, p.y];
                    })
                );
                this._addMultiLine(path, spatialReference, id);
            } else if (geometry.type === geometryTypes.POLYGON) {
                const rings = geometry.ringArray.map(ring =>
                    ring.pointArray.map(point => {
                        const p = point.xy.projectToPoint(spatialReference);
                        return [p.x, p.y];
                    })
                );
                const style = {
                    outlineColor: geometry.outlineColor,
                    outlineWidth: geometry.outlineWidth,
                    fillColor: geometry.fillColor,
                    fillOpacity: geometry.fillOpacity
                };
                this._addPolygon(rings, spatialReference, id, style);
            } else if (geometry.type === geometryTypes.MULTIPOLYGON) {
                const multiPolyRings = geometry.polygonArray.map(polygon =>
                    polygon.ringArray.map(ring =>
                        ring.pointArray.map(point => {
                            const p = point.xy.projectToPoint(spatialReference);
                            return [p.x, p.y];
                        })
                    )
                );
                const rings = [].concat.apply([], multiPolyRings);
                const multiPolyStyle = {
                    outlineColor: geometry.outlineColor,
                    outlineWidth: geometry.outlineWidth,
                    fillColor: geometry.fillColor,
                    fillOpacity: geometry.fillOpacity
                };

                // addPolygon functions works for MultiPolygon as well, since we set up the rings in the proper format
                this._addPolygon(rings, spatialReference, id, multiPolyStyle);
            }
        });
    }

    /**
     * Add a point where specified using longitute and latitute.
     *
     * @function _addPoint
     * @private
     * @param {Object} coords                    the long and lat to use as the graphic location
     * @param {Object} spatialReference          the projection the graphics should be in
     * @param {String} icon                      data / image url or svg path for layer icon. defaults to a red point
     * @param {String} id                        id of api geometry being added to map
     */
    _addPoint(coords, spatialReference, icon, id) {
        const point = new this._bundle.Point({
            x: coords.x,
            y: coords.y,
            spatialReference: spatialReference
        });

        let symbol, marker;
        if (icon) {
            if (this._isUrl(icon)) {
                // TODO: discuss how to handle the width / height issue when passing in an icon
                symbol = new this._bundle.PictureMarkerSymbol(icon, 16.5, 16.5);
            } else {
                symbol = new this._bundle.SimpleMarkerSymbol();
                symbol.setPath(icon);
                symbol.setColor([255, 0, 0]);
                symbol.setSize('auto');
            }
            marker = new this._bundle.Graphic(point, symbol);
        } else {
            marker = new this._bundle.Graphic({ symbol: defaultSymbols.POINT });
            marker.setGeometry(point);
        }

        marker.geometry.apiId = id;
        this._layer.add(marker);
    }

    /**
     * Add multiple points where specified using the longitutes and latitutes.
     *
     * @function _addMultiPoint
     * @private
     * @param {Array} coords                     a 3D array of long and lat to use as the graphic location for each point
     * @param {Object} spatialReference          the projection the graphics should be in
     * @param {String} icon                      data / image url or svg path for layer icon. defaults to a green point
     * @param {String} id                        id of api geometry being added to map
     */
    _addMultiPoint(coords, spatialReference, icon, id) {
        const points = new this._bundle.Multipoint({
            points: coords,
            spatialReference: spatialReference
        });

        let symbol, marker;
        if (icon) {
            if (this._isUrl(icon)) {
                // TODO: discuss how to handle the width / height issue when passing in an icon
                symbol = new this._bundle.PictureMarkerSymbol(icon, 16.5, 16.5);
            } else {
                symbol = new this._bundle.SimpleMarkerSymbol();
                symbol.setPath(icon);
                symbol.setColor([255, 0, 0]);
                symbol.setSize('auto');
            }
            marker = new this._bundle.Graphic(points, symbol);
        } else {
            marker = new this._bundle.Graphic({ symbol: defaultSymbols.MULTIPOINT });
            marker.setGeometry(points);
        }

        marker.geometry.apiId = id;
        this._layer.add(marker);
    }

    /**
     * Add a line where specified using the path of longitutes and latitutes.
     *
     * @function _addLine
     * @private
     * @param {Array} path                       an array of long and lat to use as the path for the line
     * @param {Object} spatialReference          the projection the graphics should be in
     * @param {String} id                        id of api geometry being added to map
     */
    _addLine(path, spatialReference, id) {
        const line = new this._bundle.Polyline({
            paths: [ path ],
            spatialReference: spatialReference
        });

        const marker = new this._bundle.Graphic({ symbol: defaultSymbols.LINESTRING });
        marker.setGeometry(line);

        marker.geometry.apiId = id;
        this._layer.add(marker);
    }

    /**
     * Add multiple lines where specified using the path of longitutes and latitutes.
     *
     * @function _addMultiLine
     * @private
     * @param {Array} paths                      a 3D array of long and lat to use as the paths for the lines
     * @param {Object} spatialReference          the projection the graphics should be in
     * @param {String} id                        id of api geometry being added to map
     */
    _addMultiLine(paths, spatialReference, id) {
        const lines = new this._bundle.Polyline({
            paths,
            spatialReference
        });

        const marker = new this._bundle.Graphic({ symbol: defaultSymbols.MULTILINESTRING });
        marker.setGeometry(lines);

        marker.geometry.apiId = id;
        this._layer.add(marker);
    }

    /**
     * Add a polygon where specified using the rings provided.
     *
     * @function _addPolygon
     * @private
     * @param {Array} rings                      an array of rings containing an array of coordinates
     * @param {Object} spatialReference          the projection the graphics should be in
     * @param {String} id                        id of api geometry being added to map
     * @param {Object} style                     settings such as color and opacity for the polygon
     */
    _addPolygon(rings, spatialReference, id, style) {
        const polygon = new this._bundle.Polygon({
            rings,
            spatialReference
        });

        const lineSymbol = new this._bundle.SimpleLineSymbol();
        lineSymbol.setColor(style.outlineColor);
        lineSymbol.setWidth(style.outlineWidth);

        const fillColor = new this._bundle.Color(style.fillColor);
        fillColor.a = style.fillOpacity;

        const fillSymbol = new this._bundle.SimpleFillSymbol();
        fillSymbol.setColor(fillColor);
        fillSymbol.setOutline(lineSymbol);

        const marker = new this._bundle.Graphic(polygon, fillSymbol);

        marker.geometry.apiId = id;
        this._layer.add(marker);
    }

    /**
     * Remove the specified graphic.
     *
     * @function removeGeometry
     * @param {Number} index      index of the graphic to remove from the layer
     */
    removeGeometry(index) {
        const graphic = this._layer.graphics[index];
        this._layer.remove(graphic);
    };

    /**
     * Check to see if text provided is a valid image / data URL based on extension type or format.
     *
     * @function _isUrl
     * @private
     * @param {String} text                      string to be matched against valid image types / data url format
     * @returns {Boolean}                        true if valid image extension
     */
    _isUrl(text) {
        return !!text.match(/\.(jpeg|jpg|gif|png|swf|svg)$/) ||
            !!text.match(/^\s*data:([a-z]+\/[a-z]+(;[a-z\-]+\=[a-z\-]+)?)?(;base64)?,[a-z0-9\!\$\&\'\,\(\)\*\+\,\;\=\-\.\_\~\:\@\/\?\%\s]*\s*$/i);
    }

    /**
     * Return the extent of an array of graphics.
     *
     * @function getGraphicsBoundingBox
     * @param {Array<Graphic>} graphics      the graphics whose bounding box we want to calculate
     * @returns {Object}                     the extent of an array of graphics
     */
    getGraphicsBoundingBox(graphics) {
        return this._apiRef.proj.graphicsUtils.graphicsExtent(graphics);
    };
}

module.exports = () => ({
    GraphicsRecord
});