'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 pointStyles = {
CIRCLE: 'esriSMSCircle',
CROSS: 'esriSMSCross',
DIAMOND: 'esriSMSDiamond',
SQUARE: 'esriSMSSquare',
X: 'esriSMSX',
TRIANGLE: 'esriSMSTriangle'
}
const lineStyles = {
DASH: 'esriSLSDash',
DASHDOT: 'esriSLSDashDot',
DASHDOTDOT: 'esriSLSDashDotDot',
DOT: 'esriSLSDot',
NULL: 'esriSLSNull',
SOLID: 'esriSLSSolid'
}
const fillStyles = {
BDIAG: 'backwarddiagonal',
CROSS: 'cross',
DIAG_CROSS: 'diagonalcross',
FDIAG: 'forwarddiagonal',
HORIZONTAL: 'horizontal',
NULL: 'none',
SOLID: 'solid',
VERTICAL: 'vertical'
}
const llSR = {
wkid: 4326
}
/**
* @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._hoverEvent = new shared.FakeEvent();
}
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) {
return this._hoverEvent.addListener(listenerCallback);
}
/**
* Remove a mouse hover listener.
*
* @function removeHoverListener
* @param {Function} listenerCallback function to not call when a hover event happens
*/
removeHoverListener (listenerCallback) {
this._hoverEvent.removeListener(listenerCallback);
}
/**
* 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) {
const showBundle = {
type: 'mouseOver',
point: e.screenPoint,
target: e.target,
graphic: e.graphic
};
// tell anyone listening we moused into something
this._hoverEvent.fireEvent(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._hoverEvent.fireEvent(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) {
// for each geometry, figure out what type it is, massage the data
// to a format that our internal libraries can use, then call the
// appropriate _add function to get it on the map.
const geometries = Array.isArray(geo) ? geo : [ geo ];
geometries.forEach(geometry => {
const id = geometry.id;
if (geometry.type === geometryTypes.POINT) {
const coords = geometry.xy.toArray();
const icon = geometry.styleOptions.icon;
this._addPoint(coords, spatialReference, icon, id, geometry.styleOptions);
} else if (geometry.type === geometryTypes.MULTIPOINT) {
const points = geometry.pointArray.map(p => p.xy.toArray());
const icon = geometry.styleOptions.icon;
this._addMultiPoint(points, spatialReference, icon, id, geometry.styleOptions);
} else if (geometry.type === geometryTypes.LINESTRING) {
const path = geometry.pointArray.map(p => p.xy.toArray());
this._addLine(path, spatialReference, id, geometry.styleOptions);
} else if (geometry.type === geometryTypes.MULTILINESTRING) {
const path = geometry.lineArray.map(line =>
line.pointArray.map(p => p.xy.toArray())
);
this._addMultiLine(path, spatialReference, id, geometry.styleOptions);
} else if (geometry.type === geometryTypes.POLYGON) {
const rings = geometry.ringArray.map(ring =>
ring.pointArray.map(p => p.xy.toArray())
);
this._addPolygon(rings, spatialReference, id, geometry.styleOptions);
} else if (geometry.type === geometryTypes.MULTIPOLYGON) {
const multiPolyRings = geometry.polygonArray.map(polygon =>
polygon.ringArray.map(ring =>
ring.pointArray.map(p => p.xy.toArray())
)
);
const rings = [].concat.apply([], multiPolyRings);
// addPolygon functions works for MultiPolygon as well, since we set up the rings in the proper format
this._addPolygon(rings, spatialReference, id, geometry.styleOptions);
}
});
}
/**
* 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 desired 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
* @param {Object} opts options to apply to point
*/
_addPoint(coords, spatialReference, icon, id, opts) {
const projPt = this._apiRef.proj.localProjectPoint(llSR, spatialReference, coords);
const point = new this._bundle.Point({
x: projPt[0],
y: projPt[1],
spatialReference
});
let symbol, marker;
if (opts.style === '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, opts.width, opts.height);
symbol.setOffset(opts.xOffset, opts.yOffset);
} else {
symbol = new this._bundle.SimpleMarkerSymbol();
symbol.setPath(icon);
symbol.setColor(opts.colour);
symbol.setSize(opts.width);
symbol.setOffset(opts.xOffset, opts.yOffset);
}
marker = new this._bundle.Graphic(point, symbol);
} else {
const options = {
color: opts.colour,
size: opts.width,
xoffset: opts.xOffset,
yoffset: opts.yOffset,
type: 'esriSMS',
style: pointStyles[opts.style],
outline: {
color: [0, 0, 0],
width: 1,
type: 'esriSLS',
style: 'esriSLSSolid'
}
}
symbol = new this._bundle.SimpleMarkerSymbol(options);
marker = new this._bundle.Graphic(point, symbol);
}
marker.geometry.apiId = id;
marker.geometry.geomType = "esriGeometryPoint"; // trickery for the zooming
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 desired 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
* @param {Object} opts options to apply to points
*/
_addMultiPoint(coords, spatialReference, icon, id, opts) {
const llPoints = new this._bundle.Multipoint({
points: coords,
spatialReference: llSR
});
const projPoints = this._apiRef.proj.localProjectGeometry(spatialReference, llPoints);
const points = new this._bundle.Multipoint(projPoints);
let symbol, marker;
if (opts.style === '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 {
const options = {
color: opts.colour,
size: opts.width,
xoffset: opts.xOffset,
yoffset: opts.yOffset,
type: 'esriSMS',
style: pointStyles[opts.style],
outline: {
color: [0, 0, 0],
width: 1,
type: 'esriSLS',
style: 'esriSLSSolid'
}
}
symbol = new this._bundle.SimpleMarkerSymbol(options);
marker = new this._bundle.Graphic(points, symbol);
}
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 desired projection the graphics should be in
* @param {String} id id of api geometry being added to map
* @param {Object} opts options to apply to line
*/
_addLine(path, spatialReference, id, opts) {
this._addMultiLine([path], spatialReference, id, opts);
}
/**
* 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 desired projection the graphics should be in
* @param {String} id id of api geometry being added to map
* @param {Object} opts options to apply to lines
*/
_addMultiLine(paths, spatialReference, id, opts) {
const llLine = new this._bundle.Polyline({
paths: paths,
spatialReference: llSR
});
const projLine = this._apiRef.proj.localProjectGeometry(spatialReference, llLine);
const lines = new this._bundle.Polyline(projLine);
const symbol = {
width: opts.width,
type: 'esriSLS',
color: opts.colour,
style: opts.style,
outline: {
color: [0, 0, 0],
width: 1,
type: 'esriSLS',
style: 'esriSLSSolid'
}
}
const marker = new this._bundle.Graphic({ symbol: symbol });
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 long-lat coordinates
* @param {Object} spatialReference the desired projection the graphics should be in
* @param {String} id id of api geometry being added to map
* @param {Object} opts settings such as color and opacity for the polygon
*/
_addPolygon(rings, spatialReference, id, opts) {
const llPoly = new this._bundle.Polygon({
rings,
spatialReference: llSR
});
const projPoly = this._apiRef.proj.localProjectGeometry(spatialReference, llPoly);
const polygon = new this._bundle.Polygon(projPoly);
const lineSymbol = new this._bundle.SimpleLineSymbol();
lineSymbol.setColor(opts.outlineColor);
lineSymbol.setWidth(opts.outlineWidth);
lineSymbol.setStyle(lineStyles[opts.outlineStyle]);
const fillColor = new this._bundle.Color(opts.fillColor);
fillColor.a = opts.fillOpacity;
const fillSymbol = new this._bundle.SimpleFillSymbol();
fillSymbol.setStyle(fillStyles[opts.fillStyle]);
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);
};
/**
* Will attempt to zoom the map view so the a graphic is prominent.
*
* @function zoomToGraphic
* @param {String} apiId API ID of grahpic being searched for. This is a string name from api, not an esri OID.
* @param {Object} map wrapper object for the map we want to zoom
* @param {Object} offsetFraction an object with decimal properties `x` and `y` indicating percentage of offsetting on each axis
* @return {Promise} resolves after the map is done moving
*/
zoomToGraphic (apiId, map, offsetFraction) {
// TODO this is a hacky re-write of the standard attribFC.zoomToGraphic.
// we need a function quickly, so doing it this way.
// the proper way would be to build in a nice zoom to graphic as part of
// the api, and clean up the logic here or somehow merge logic with attribFC (a larger task)
// find graphic by api id
const targGraphic = this._layer.graphics.find(g => g.geometry.apiId === apiId);
if (targGraphic) {
const gapi = this._apiRef;
// we know graphic is real graphic, and in map projection.
// we also assume there is no scale level dependency to worry about.
const extent = gapi.proj.graphicsUtils.graphicsExtent([targGraphic]);
let geomZoomPromise;
if (targGraphic.geometry.geomType === 'esriGeometryPoint') {
// zoom to point at a decent scale for hilighting a point
const sweetLod = gapi.Map.findClosestLOD(map.lods, 50000);
geomZoomPromise = map.centerAndZoom(extent.getCenter(), Math.max(sweetLod.level, 0));
} else {
// zoom to the extent of the geometery
geomZoomPromise = map.setExtent(extent, true);
}
return geomZoomPromise.then(() => {
// we have zoomed to our graphic. now position our map
return map.moveToOffsetExtent(extent, offsetFraction);
});
} else {
// TODO handle error correctly
console.warn(`could not find graphic ${apiId}`);
return Promise.resolve();
}
}
}
module.exports = () => ({
GraphicsRecord
});