'use strict';
const basemap = require('./basemap.js');
const mapPrint = require('./print.js');
function esriMap(esriBundle, geoApi) {
const printModule = mapPrint(esriBundle);
let basemapErrored = false;
let basemaps = null;
let corsEverywhere = false;
let overviewExpand = null;
class Map {
static get Extent () { return esriBundle.Extent; }
// TODO when jshint parses instance fields properly we can change this from a property to a field
get _passthroughBindings () { return [
'addLayer', 'centerAndZoom', 'centerAt', 'destroy', 'disableKeyboardNavigation', 'getLevel',
'getScale', 'on', 'removeLayer', 'reorderLayer', 'reposition', 'resize', 'setExtent',
'setMapCursor', 'setScale', 'setZoom', 'toMap', 'toScreen'
]; }
get _passthroughProperties () { return [
'attribution', 'extent', 'graphicsLayerIds', 'height', 'layerIds', 'spatialReference', 'width'
]; } // TODO when jshint parses instance fields properly we can change this from a property to a field
/*
* Option params
* - basemaps: array of basemap options. See config schema baseMapNode
* - scalebar: object to show scalebar. Has .enabled property, and optional .attachTo and .scalebarUnit properties
* - overviewMap: object to show overview map. Has .enabled and .expandFactor properties
* - extent: extent object for initial extent
* - lods: array of level of details. See config schema lodSetNode.lods
* - tileSchema: object describing schema of map. See config schema tileSchemaNode
* - proxyUrl: url to proxy for use by mapping api. optional
* - corsEverywhere: boolean to be set if every layer on the map is CORS enabled, mutually exclusive with proxyUrl, optional
*
* @param {Object} domNode the DOM node where the map will be created
* @param {Object} opts options object for the map (see above)
*/
constructor (domNode, opts) {
this._passthroughBindings.forEach(bindingName =>
this[bindingName] = (...args) => this._map[bindingName](...args));
this._passthroughProperties.forEach(propName => {
const descriptor = {
enumerable: true,
get: () => this._map[propName]
};
Object.defineProperty(this, propName, descriptor);
});
this._map = new esriBundle.Map(domNode, {
extent: Map.getExtentFromJson(opts.extent),
lods: opts.lods,
fitExtent: true
});
if (opts.proxyUrl) {
this.proxy = opts.proxyUrl;
}
if (opts.corsEverywhere) {
if (this.corsEverywhere === true && this.proxy) {
throw new Error('proxyUrl and corsEverywhere are mutually exclusive');
}
corsEverywhere = opts.corsEverywhere;
}
if (opts.basemaps) {
basemaps = opts.basemaps;
basemaps.forEach(bm => {
bm._layers.forEach(l => this.checkCorsException(l.url));
})
} else {
throw new Error('The basemaps option is required to and at least one basemap must be defined');
}
if (opts.scalebar && opts.scalebar.enabled) {
this.scalebar = new esriBundle.Scalebar({
map: this._map,
attachTo: opts.scalebar.attachTo,
scalebarUnit: opts.scalebar.scalebarUnit
});
this.scalebar.show();
}
if (opts.overviewMap && opts.overviewMap.enabled) {
overviewExpand = opts.overviewMap.expandFactor;
if (opts.tileSchema.overviewUrl) {
// initial implementation. we only are supporting tile layers.
// if we want to enhance to have other layer types, will need to determine
// how to go about it. we could just use raw objects in a switch statement here,
// or attempt to wire in the layer records.
this.checkCorsException(opts.tileSchema.overviewUrl.url);
this.defaultOverview = false;
const customOverview = new esriBundle.ArcGISTiledMapServiceLayer(opts.tileSchema.overviewUrl.url);
customOverview.on('load', () => {
this.initOverviewMap(overviewExpand, customOverview);
});
} else {
// we use the active basemap, and reset the overview whenever it changes
this.defaultOverview = true;
this.initOverviewMap(overviewExpand);
}
}
this.zoomPromise = Promise.resolve();
this.zoomCounter = 0;
}
checkCorsException(url) {
if (corsEverywhere) {
const hostRegex = /^(?:https?:\/\/)?(?:[^@\/\n]+@)?([^:\/\n]+)/i;
const match = hostRegex.exec(url);
if (match !== null) {
const hostname = match[1];
if (esriBundle.esriConfig.defaults.io.corsEnabledServers.indexOf(hostname) < 0) {
console.debug('layer added cors ', hostname);
esriBundle.esriConfig.defaults.io.corsEnabledServers.push(hostname);
}
}
}
}
initGallery() {
this.basemapGallery = basemap.initBasemaps(esriBundle, basemaps, this._map);
if (overviewExpand !== null) {
this.basemapGallery.on('selection-change', () => {
if (this.defaultOverview) this.resetOverviewMap(overviewExpand)
});
this.basemapGallery.on('error', () => {
this.overviewMap.destroy();
basemapErrored = true;
});
}
}
printLocal (options) { return printModule.printLocal(this._map, options); }
printServer (options) { return printModule.printServer(this._map, options); }
/**
* Select a basemap which has been loaded in the basemapGallery
*
* @param {Object|String} value either an object with an id field or a string
*/
selectBasemap (value) {
if (typeof value === 'object') {
value = value.id;
}
this.basemapGallery.select(value);
}
/**
* Remove a basemap from the basemapGallery
*
* @param {Object|String} value either an object with an id field or a string
*/
removeBasemap (value) {
if (typeof value === 'object') {
value = value.id;
}
this.basemapGallery.remove(value);
}
/**
* Add a basemap to the basemapGallery
*
* @param {Object} basemapConfig a basemap JSON snippet
*/
addBasemap (basemapConfig) {
const basemapToAdd = basemap.createBasemap(esriBundle, basemapConfig);
this.basemapGallery.add(basemapToAdd);
}
/**
* Create an ESRI Extent object from extent setting JSON object.
*
* @function getExtentFromJson
* @param {Object} extentJson that follows config spec
* @return {Object} an ESRI Extent object
*/
static getExtentFromJson (extentJson) {
return esriBundle.Extent(extentJson);
}
/**
* Take a JSON object with extent properties and convert it to an ESRI Extent.
* Reprojects to map projection if required.
*
* @param {Object} extent the extent to enhance
* @returns {Extent} extent cast in Extent prototype, and in map spatial reference
*/
enhanceConfigExtent (extent) {
const realExtent = Map.getExtentFromJson(extent);
if (geoApi.proj.isSpatialRefEqual(this._map.spatialReference, extent.spatialReference)) {
return realExtent;
} else {
return geoApi.proj.projectEsriExtent(realExtent, this._map.spatialReference);
}
}
/**
* Takes a location object in lat/long, converts to current map spatialReference using
* reprojection method in geoApi, and zooms to the point.
*
* @function zoomToLatLong
* @param {Object} location is a location object, containing geometries in the form of { longitude: <Number>, latitude: <Number> }
*/
zoomToPoint ({ longitude, latitude }) {
// get reprojected point and zoom to it
const geoPt = geoApi.proj.localProjectPoint(4326, this._map.spatialReference,
[parseFloat(longitude), parseFloat(latitude)]);
const zoomPt = geoApi.proj.Point(geoPt[0], geoPt[1], this._map.spatialReference);
// give preference to the layer closest to a 50k scale ratio which is ideal for zoom
const sweetLod = Map.findClosestLOD(this.lods, 50000);
this._map.centerAndZoom(zoomPt, Math.max(sweetLod.level, 0));
}
/**
* Zoom the map to an extent. Extent can be in different projection
*
* @function zoomToExtent
* @param {Object} extent map object we want to execute the zoom on
* @private
* @return {Promise} resolves when map is done zooming
*/
zoomToExtent (extent) {
// TODO add some caching? make sure it will get wiped if we end up changing projections
// or use wkid as caching key?
const projRawExtent = geoApi.proj.localProjectExtent(extent, this._map.spatialReference);
const projFancyExtent = esriBundle.Extent(projRawExtent.x0, projRawExtent.y0,
projRawExtent.x1, projRawExtent.y1, projRawExtent.sr);
return this._map.setExtent(projFancyExtent, true);
}
/**
* Finds the level of detail closest to the provided scale.
*
* @function findClosestLOD
* @param {Array} lods list of levels of detail objects
* @param {Number} scale scale value to search for in the levels of detail
* @return {Object} the level of detail object closest to the scale
*/
static findClosestLOD (lods, scale) {
const diffs = lods.map(lod => Math.abs(lod.scale - scale));
const lodIdx = diffs.indexOf(Math.min(...diffs));
return lods[lodIdx];
}
/**
* Calculate north arrow bearing. Angle returned is to to rotate north arrow image.
* http://www.movable-type.co.uk/scripts/latlong.html
* @function getNorthArrowAngle
* @param {Object} opts options to apply to north arrow calculation
* @returns {String} map rotation angle (in degree) in string format
*/
getNorthArrowAngle (opts) {
// get center point in longitude and use bottom value for latitude for default point
const bottomCenter = { x: (this._map.extent.xmin + this._map.extent.xmax) / 2, y: this._map.extent.ymin };
// get point if specified by caller else get default
const point = opts ? opts.point || bottomCenter : bottomCenter;
try {
const pointB = geoApi.proj.localProjectPoint(this._map.extent.spatialReference, 'EPSG:4326', point);
// north value (set longitude to be half of Canada extent (141° W, 52° W))
const pointA = { x: -96, y: 90 };
// set info on longitude and latitude
const dLon = (pointB.x - pointA.x) * Math.PI / 180;
const lat1 = pointA.y * Math.PI / 180;
const lat2 = pointB.y * Math.PI / 180;
// calculate bearing
const y = Math.sin(dLon) * Math.cos(lat2);
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
const bearing = Math.atan2(y, x) * 180 / Math.PI;
// return angle (180 is pointiong north)
return ((bearing + 360) % 360).toFixed(1);
} catch(error) {
return '180.0';
}
}
/**
* Calculate distance between min and max extent to know the pixel ratio between
* screen size and earth distance.
* http://www.movable-type.co.uk/scripts/latlong.html
* @function getScaleRatio
* @param {Number} mapWidth optional the map width to use to calculate ratio
* @returns {Object} contain information about the scale
* - distance: distance between min and max extentId
* - ratio: measure for 1 pixel in earth distance
* - units: array of units [metric, imperial]
*/
getScaleRatio (mapWidth = 0) {
const map = this._map;
// get left and right maximum value point to calculate distance from
const pointA = geoApi.proj.localProjectPoint(map.spatialReference, 'EPSG:4326',
{ x: map.extent.xmin, y: (map.extent.ymin + map.extent.ymax) / 2 });
const pointB = geoApi.proj.localProjectPoint(map.spatialReference, 'EPSG:4326',
{ x: map.extent.xmax, y: (map.extent.ymin + map.extent.ymax) / 2 });
// Haversine formula to calculate distance
const R = 6371e3; // earth radius in meters
const rad = Math.PI / 180;
const phy1 = pointA.y * rad; // radiant
const phy2 = pointB.y * rad; // radiant
const deltaPhy = (pointB.y - pointA.y) * rad; // radiant
const deltaLambda = (pointB.x - pointA.x) * rad; // radiant
const a = Math.sin(deltaPhy / 2) * Math.sin(deltaPhy / 2) +
Math.cos(phy1) * Math.cos(phy2) *
Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const d = (R * c);
// set map / image width (if mapWidth = 0, use map.width)
const width = mapWidth ? mapWidth : map.width;
// get unit from distance, set distance and ratio (earth size for 1 pixel)
const units = [(d > 1000) ? 'km' : 'm', (d > 1600) ? 'mi' : 'ft'];
const distance = (d > 1000) ? d / 1000 : d;
const ratio = distance / width;
return { distance, ratio, units };
}
/**
* Compares to sets of co-ordinates for extents (valid for both x and y). If center of input co-ordinates falls outside
* map co-ordiantes, function will adjust them so the center is inside the map co-ordinates.
*
* @function clipExtentCoords
* @private
* @param {Numeric} mid middle of the the range to test
* @param {Numeric} max maximum value of the range to test
* @param {Numeric} min minimum value of the range to test
* @param {Numeric} mapMax maximum value of the map range
* @param {Numeric} mapMin minimum value of the map range
* @param {Numeric} len length of the adjusted range, if adjusted
* @return {Array} two element array of Numeric, containing result max and min values
*/
static clipExtentCoords (mid, max, min, mapMax, mapMin, len) {
if (mid > mapMax) {
[max, min] = [mapMax, mapMax - len];
} else if (mid < mapMin) {
[max, min] = [mapMin + len, mapMin];
}
return [max, min];
}
/**
* Checks if the center of the given extent is outside of the maximum extent. If it is,
* will determine an adjusted extent with a center inside the maximum extent. Returns both
* an indicator flag if an adjustment happened, and the adjusted extent.
*
* @function enforceBoundary
* @param {Object} extent an ESRI extent to test
* @param {Object} maxExtent an ESRI extent indicating the boundary of the map
* @return {Object} an object with two properties. adjusted - boolean, true if extent was adjusted. newExtent - object, adjusted ESRI extent
*/
static enforceBoundary (extent, maxExtent) {
// clone extent
const newExtent = esriBundle.Extent(extent.toJson());
// determine dimensions of adjusted extent.
// same as input, unless input is so large it consumes max.
// in that case, we shrink to the max. This avoids the "washing machine"
// bug where we over-correct past the valid range,
// and achieve infinite oscillating pans
const height = Math.min(extent.getHeight(), maxExtent.getHeight());
const width = Math.min(extent.getWidth(), maxExtent.getWidth());
const center = extent.getCenter();
[newExtent.xmax, newExtent.xmin] =
this.clipExtentCoords(center.x, newExtent.xmax, newExtent.xmin, maxExtent.xmax, maxExtent.xmin, width);
[newExtent.ymax, newExtent.ymin] =
this.clipExtentCoords(center.y, newExtent.ymax, newExtent.ymin, maxExtent.ymax, maxExtent.ymin, height);
return {
newExtent,
adjusted: !extent.contains(newExtent) // true if we adjusted the extent
};
}
initOverviewMap (expandFactor, baseLayer) {
if (basemapErrored) {
basemapErrored = false;
return;
}
basemapErrored = false;
const opts = {
map: this._map,
expandFactor,
visible: true
};
if (baseLayer) {
opts.baseLayer = baseLayer;
}
let hasBaseLayer = false;
Object.keys(opts.map._layers).forEach(id => {
const layer = opts.map._layers[id];
if (layer._basemapGalleryLayerType === 'basemap') {
hasBaseLayer = true;
}
});
if (opts.baseLayer || hasBaseLayer) {
this.overviewMap = new esriBundle.OverviewMap(opts);
this.overviewMap.startup();
}
}
resetOverviewMap (expandFactor) {
if (this.overviewMap) {
this.overviewMap.destroy();
}
this.initOverviewMap(expandFactor);
}
/**
* Changes the zoom level by the specified value relative to the current level; can be negative.
* To avoid multiple chained zoom animations when rapidly pressing the zoom in/out icons, we
* update the zoom level only when the one before it resolves with the net zoom change.
*
* @function shiftZoom
* @param {number} byValue a number of zoom levels to shift by
*/
shiftZoom (byValue) {
this.zoomCounter += byValue;
// when using keys for navigation esri throws an internal exception which cannot be caught when `centerAt` is called right after `setZoom`
// so far, we could not reproduce it by calling these two functions manually in the console, so there must be another factor involved
// when this internal exception is thrown, zoomPromise get's rejected
// calling `then` on a rejected promise does not work which prevents further zoom actions triggered throught the keyboard
// calling `catch` on a rejected promise works, and the promise can be reset
// calling `finally` on a rejected promise works as well, and this can be used to reset the promise and trigger further zoom actions
// NOTE: this is not an ideal solution, but unless the third factor causing errors in `centerAt/setZoom` calls can be found, the internal esri exceptions needs to be ignored
this.zoomPromise.finally(() => {
if (this.zoomCounter !== 0) {
const zoomValue = this._map.getZoom() + this.zoomCounter;
const zoomPromise = Promise.resolve(this.setZoom(zoomValue));
this.zoomCounter = 0;
// undefined signals we've zoomed in/out as far as we can
if (typeof zoomPromise !== 'undefined') {
this.zoomPromise = zoomPromise;
}
}
});
}
/**
* Sets or gets map default config values.
*
* @function mapDefault
* @param {String} key name of the default property
* @param {Any} [value] optional value to set for the specified default property
*/
mapDefault (key, value) {
if (typeof value === 'undefined') {
return esriBundle.esriConfig.defaults.map[key];
} else {
esriBundle.esriConfig.defaults.map[key] = value;
}
}
/**
* Will position the map so that the target extent is in view. Offsetting is available
* to allow the view to take into account UI elements that cover the map
* (e.g. legend and grid are open, so want extent visible in remaining map area)
*
* @function moveToOffsetExtent
* @param {Object} targetExtent an ESRI extent to position the map to
* @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
*/
moveToOffsetExtent (targetExtent, offsetFraction) {
const currentExtent = this.extent;
let xOffset = currentExtent.getWidth() * -offsetFraction.x;
let yOffset = currentExtent.getHeight() * offsetFraction.y;
if (currentExtent.getWidth() < targetExtent.getWidth() ||
currentExtent.getHeight() < targetExtent.getHeight()) {
// the target extent doesn't fit in the current extent,
// offset the target extent using provided fractions
xOffset = targetExtent.getWidth() * -offsetFraction.x;
yOffset = targetExtent.getHeight() * offsetFraction.y;
}
const point = targetExtent.getCenter();
const offsetCenter = point.offset(xOffset, yOffset);
return this.centerAt(offsetCenter);
}
/**
* Set proxy service URL to avoid same origin issues.
*/
set proxy (proxyUrl) { esriBundle.esriConfig.defaults.io.proxyUrl = proxyUrl; }
get proxy () { return esriBundle.esriConfig.defaults.io.proxyUrl; }
set basemapGallery (val) { this._basemapGallery = val; }
get basemapGallery () { return this._basemapGallery; }
set scalebar (val) { this._scalebar = val; }
get scalebar () { return this._scalebar; }
set overviewMap (val) { this._overviewMap = val; }
get overviewMap () { return this._overviewMap; }
// TODO an alternate approach: store opts.lods in the constructor and return that here.
// need to consider impact (it could be .__tileInfo adjusts to current basemap, thus
// preventing us from zooming to lods that have no tiles)
get lods () { return this._map.__tileInfo.lods; }
// use of the following property is unsupported by ramp team.
// it is provided for plugin developers who want to write advanced geo functions
// and wish to directly consume the esri api objects AT THEIR OWN RISK !!! :'O !!!
get esriMap () { return this._map; }
}
return Map;
}
// provides a wrapper class for a map control.
// this file in particular wraps an esri map
module.exports = (esriBundle, geoApi) => esriMap(esriBundle, geoApi);