layer/layerRec/layerRecord.js

  1. 'use strict';
  2. const layerInterface = require('./layerInterface.js')();
  3. const shared = require('./shared.js')();
  4. const root = require('./root.js')();
  5. /**
  6. * @class LayerRecord
  7. */
  8. class LayerRecord extends root.Root {
  9. // NOTE: we used to override layerClass in each specific class.
  10. // since we require the class in the generic constructor,
  11. // and since it was requested that the esri class be passed in
  12. // as a constructor parameter instead of holding a ref to the esriBundle,
  13. // and since you must call `super` first in a constructor,
  14. // it was impossible to assign the specific class before the generic
  15. // constructor executed, resulting in null-dereferences.
  16. // this approach solves the problem.
  17. get layerClass () { return this._layerClass; }
  18. get config () { return this.initialConfig; } // TODO: add a live config reference if needed
  19. get legendEntry () { return this._legendEntry; } // legend entry class corresponding to those defined in legend entry service
  20. set legendEntry (value) { this._legendEntry = value; } // TODO: determine if we still link legends inside this class
  21. get state () { return this._state; }
  22. set state (value) { this._state = value; }
  23. get layerId () { return this.config.id; }
  24. get rootUrl () { return this._rootUrl; }
  25. set rootUrl (value) { this._rootUrl = value; }
  26. // TODO should probably remove passthrough bindings?
  27. get _layerPassthroughBindings () { return ['setOpacity', 'setVisibility']; } // TODO when jshint parses instance fields properly we can change this from a property to a field
  28. get _layerPassthroughProperties () { return ['visibleAtMapScale', 'visible', 'spatialReference']; } // TODO when jshint parses instance fields properly we can change this from a property to a field
  29. get userLayer () { return this._user; } // indicates if layer was added by a user
  30. set userLayer (value) { this._user = value; }
  31. // really this is the client layer type. how it is implemented in the map stack.
  32. // layerType is implemented by the classes that inherit LayerRecord. So if someone forgets
  33. // they will get a lovely null here to remind them.
  34. // in the case of a record, the implementation will usually match the record type.
  35. get parentLayerType () { return this.layerType; }
  36. get visibility () {
  37. if (this._layer) {
  38. return this._layer.visible;
  39. } else {
  40. return true; // TODO what should a proper default be? example of this situation??
  41. }
  42. }
  43. set visibility (value) {
  44. if (this._layer) {
  45. this._layer.setVisibility(value);
  46. }
  47. // TODO do we need an ELSE case here?
  48. }
  49. get opacity () {
  50. if (this._layer) {
  51. return this._layer.opacity;
  52. } else {
  53. return 1; // TODO what should a proper default be? example of this situation??
  54. }
  55. }
  56. set opacity (value) {
  57. if (this._layer) {
  58. this._layer.setOpacity(value);
  59. }
  60. // TODO do we need an ELSE case here?
  61. }
  62. /**
  63. * Attach record event handlers to common layer events
  64. *
  65. * @function bindEvents
  66. * @param {Object} layer the api layer object
  67. */
  68. bindEvents (layer) {
  69. // TODO optional refactor. Rather than making the events object in the parameter,
  70. // do it as a variable, and only add mouse-over, mouse-out events if we are
  71. // in an app configuration that will use it. May save a bit of processing
  72. // by not having unused events being handled and ignored.
  73. // Second optional thing. Call a separate wrapEvents in FeatuerRecord class
  74. // TODO apply johann update here
  75. this._apiRef.events.wrapEvents(layer, {
  76. // wrapping the function calls to keep `this` bound correctly
  77. load: () => this.onLoad(),
  78. error: e => this.onError(e),
  79. 'update-start': () => this.onUpdateStart(),
  80. 'update-end': () => this.onUpdateEnd(),
  81. 'mouse-over': e => this.onMouseOver(e),
  82. 'mouse-out': e => this.onMouseOut(e)
  83. });
  84. }
  85. /**
  86. * Generates a new api layer object.
  87. *
  88. * @function constructLayer
  89. * @returns {Object} the new api layer object.
  90. */
  91. constructLayer () {
  92. this._layer = this.layerClass(this.config.url, this.makeLayerConfig());
  93. this.bindEvents(this._layer);
  94. return this._layer;
  95. }
  96. /**
  97. * Reacts to a layer-level state change.
  98. *
  99. * @function _stateChange
  100. * @private
  101. * @param {String} newState the state the layer has now become
  102. */
  103. _stateChange (newState) {
  104. // console.log(`State change for ${this.layerId} to ${newState}`);
  105. this._state = newState;
  106. // if we don't copy the array we could be looping on an array
  107. // that is being modified as it is being read
  108. this._fireEvent(this._stateListeners, this._state);
  109. }
  110. /**
  111. * Wire up state change listener.
  112. *
  113. * @function addStateListener
  114. * @param {Function} listenerCallback function to call when a state change event happens
  115. */
  116. addStateListener (listenerCallback) {
  117. this._stateListeners.push(listenerCallback);
  118. return listenerCallback;
  119. }
  120. /**
  121. * Remove a state change listener.
  122. *
  123. * @function removeStateListener
  124. * @param {Function} listenerCallback function to not call when a state change event happens
  125. */
  126. removeStateListener (listenerCallback) {
  127. const idx = this._stateListeners.indexOf(listenerCallback);
  128. if (idx < 0) {
  129. throw new Error('Attempting to remove a listener which is not registered.');
  130. }
  131. this._stateListeners.splice(idx, 1);
  132. }
  133. /**
  134. * Wire up mouse hover listener.
  135. *
  136. * @function addHoverListener
  137. * @param {Function} listenerCallback function to call when a hover event happens
  138. */
  139. addHoverListener (listenerCallback) {
  140. this._hoverListeners.push(listenerCallback);
  141. return listenerCallback;
  142. }
  143. /**
  144. * Remove a mouse hover listener.
  145. *
  146. * @function removeHoverListener
  147. * @param {Function} listenerCallback function to not call when a hover event happens
  148. */
  149. removeHoverListener (listenerCallback) {
  150. const idx = this._hoverListeners.indexOf(listenerCallback);
  151. if (idx < 0) {
  152. throw new Error('Attempting to remove a listener which is not registered.');
  153. }
  154. this._hoverListeners.splice(idx, 1);
  155. }
  156. /**
  157. * Triggers when the layer loads.
  158. *
  159. * @function onLoad
  160. * @returns {Array} list of promises that need to resolve for layer to be considered loaded.
  161. */
  162. onLoad () {
  163. // only super-general stuff in here, that all layers should run.
  164. console.info(`Layer loaded: ${this._layer.id}`);
  165. if (!this.name) {
  166. // no name from config. attempt layer name
  167. this.name = this._layer.name;
  168. }
  169. if (!this.extent) {
  170. // no extent from config. attempt layer extent
  171. this.extent = this._layer.fullExtent;
  172. }
  173. let lookupPromise = Promise.resolve();
  174. if (this._epsgLookup) {
  175. const check = this._apiRef.proj.checkProj(this.spatialReference, this._epsgLookup);
  176. if (check.lookupPromise) {
  177. lookupPromise = check.lookupPromise;
  178. }
  179. // TODO if we don't find a projection, the app will show the layer loading forever.
  180. // might need to handle the fail case and show something to the user.
  181. }
  182. return [lookupPromise];
  183. }
  184. /**
  185. * Triggers when the layer has an error.
  186. *
  187. * @function onError
  188. * @param {Object} e error event object
  189. */
  190. onError (e) {
  191. console.warn(`Layer error: ${e}`);
  192. console.warn(e);
  193. this._stateChange(shared.states.ERROR);
  194. }
  195. /**
  196. * Triggers when the layer starts to update.
  197. *
  198. * @function onUpdateStart
  199. */
  200. onUpdateStart () {
  201. this._stateChange(shared.states.REFRESH);
  202. }
  203. /**
  204. * Triggers when the layer finishes updating.
  205. *
  206. * @function onUpdateEnd
  207. */
  208. onUpdateEnd () {
  209. this._stateChange(shared.states.LOADED);
  210. }
  211. /**
  212. * Handles when the mouse enters a layer
  213. */
  214. onMouseOver () {
  215. // do nothing in baseclass
  216. }
  217. /**
  218. * Handles when the mouse leaves a layer
  219. */
  220. onMouseOut () {
  221. // do nothing in baseclass
  222. }
  223. /**
  224. * Creates an options object for the map API object
  225. *
  226. * @function makeLayerConfig
  227. * @returns {Object} an object with api options
  228. */
  229. makeLayerConfig () {
  230. return {
  231. id: this.config.id,
  232. opacity: this.config.state.opacity,
  233. visible: this.config.state.visibility
  234. };
  235. }
  236. /**
  237. * Figure out visibility scale. Will use layer minScale/maxScale
  238. * and map levels of detail to determine scale boundaries.
  239. *
  240. * @function findZoomScale
  241. * @param {Array} lods array of valid levels of detail for the map
  242. * @param {Object} scaleSet contains .minScale and .maxScale for valid viewing scales
  243. * @param {Boolean} zoomIn the zoom to scale direction; true need to zoom in; false need to zoom out
  244. * @returns {Object} a level of detail (lod) object for the appropriate scale to zoom to
  245. */
  246. findZoomScale (lods, scaleSet, zoomIn = true) {
  247. // TODO rename function to getZoomScale?
  248. // TODO determine if this function ever gets called when a layer is on-scale visible
  249. // TODO optional. add quick check...if our minScale/maxScale we are comparing against is 0,
  250. // then no need to search array, just take first item.
  251. // the lods array is ordered largest scale to smallest scale. e.g. world view to city view
  252. // if zoomOut is false, we reverse the array so we search it in the other direction.
  253. const modLods = zoomIn ? lods : [...lods].reverse();
  254. const scaleLod = modLods.find(currentLod => zoomIn ? currentLod.scale < scaleSet.minScale :
  255. currentLod.scale > scaleSet.maxScale);
  256. // if we did not encounter a boundary go to first
  257. return scaleLod || modLods[0];
  258. }
  259. /**
  260. * Set map scale depending on zooming in or zooming out of layer visibility scale
  261. *
  262. * @function setMapScale
  263. * @param {Object} map layer to zoom to scale to for feature layers; parent layer for dynamic layers
  264. * @param {Object} lod scale object the map will be set to
  265. * @param {Boolean} zoomIn the zoom to scale direction; true need to zoom in; false need to zoom out
  266. * @param {Boolean} positionOverLayer ensures the map is over the layer's extent after zooming. only applied if zoomIn is true. defaults to true
  267. * @returns {Promise} resolves after map is done changing its extent
  268. */
  269. setMapScale (map, lod, zoomIn, positionOverLayer = true) {
  270. // TODO possible this would live in the map manager in a bigger refactor.
  271. // NOTE because we utilize the layer object's full extent (and not child feature class extents),
  272. // this function stays in this class.
  273. // if zoom in is needed; must find center of layer's full extent and perform center&zoom
  274. if (zoomIn && positionOverLayer) {
  275. // need to reproject in case full extent in a different sr than basemap
  276. const gextent = this._apiRef.proj.localProjectExtent(this._layer.fullExtent, map.spatialReference);
  277. const reprojLayerFullExt = this._apiRef.Map.Extent(gextent.x0, gextent.y0,
  278. gextent.x1, gextent.y1, gextent.sr);
  279. // check if current map extent already in layer extent
  280. return map.setScale(lod.scale).then(() => {
  281. // if map extent not in layer extent, zoom to center of layer extent
  282. // don't need to return Deferred otherwise because setScale already resolved here
  283. if (!reprojLayerFullExt.intersects(map.extent)) {
  284. return map.centerAt(reprojLayerFullExt.getCenter());
  285. }
  286. });
  287. } else {
  288. return map.setScale(lod.scale);
  289. }
  290. }
  291. /**
  292. * Figure out visibility scale and zoom to it. Will use layer minScale/maxScale
  293. * and map levels of detail to determine scale boundaries.
  294. *
  295. * @function _zoomToScaleSet
  296. * @private
  297. * @param {Object} map the map object
  298. * @param {Array} lods level of details array for basemap
  299. * @param {Boolean} zoomIn the zoom to scale direction; true need to zoom in; false need to zoom out
  300. * @param {Object} scaleSet contains min and max scales for the layer
  301. * @param {Boolean} positionOverLayer ensures the map is over the layer's extent after zooming. only applied if zoomIn is true. defaults to true
  302. * @returns {Promise} promise that resolves after map finishes moving about
  303. */
  304. _zoomToScaleSet (map, lods, zoomIn, scaleSet, positionOverLayer = true) {
  305. // TODO update function parameters once things are working
  306. // NOTE we use lods provided by config rather that system-ish map.__tileInfo.lods
  307. const zoomLod = this.findZoomScale(lods, scaleSet, zoomIn);
  308. return this.setMapScale(map, zoomLod, zoomIn, positionOverLayer);
  309. }
  310. /**
  311. * Zoom to a valid scale level for this layer.
  312. *
  313. * @function zoomToScale
  314. * @param {Object} map the map object
  315. * @param {Array} lods level of details array for basemap
  316. * @param {Boolean} zoomIn the zoom to scale direction; true need to zoom in; false need to zoom out
  317. * @param {Boolean} positionOverLayer ensures the map is over the layer's extent after zooming. only applied if zoomIn is true. defaults to true
  318. * @returns {Promise} promise that resolves after map finishes moving about
  319. */
  320. zoomToScale (map, lods, zoomIn, positionOverLayer = true) {
  321. // get scale set from child, then execute zoom
  322. const scaleSet = this._featClasses[this._defaultFC].getScaleSet();
  323. return this._zoomToScaleSet(map, lods, zoomIn, scaleSet, positionOverLayer);
  324. }
  325. /**
  326. * Indicates if the feature class is not visible at the given scale,
  327. * and if so, if we need to zoom in to see it or zoom out
  328. *
  329. * @function isOffScale
  330. * @param {Integer} mapScale the scale to test against
  331. * @returns {Object} has boolean properties `offScale` and `zoomIn`
  332. */
  333. isOffScale (mapScale) {
  334. return this._featClasses[this._defaultFC].isOffScale(mapScale);
  335. }
  336. /**
  337. * Zoom to layer boundary of the layer specified by layerId
  338. *
  339. * @function zoomToBoundary
  340. * @param {Object} map esriMap object we want to execute the zoom on
  341. * @return {Promise} resolves when map is done zooming
  342. */
  343. zoomToBoundary (map) {
  344. return map.zoomToExtent(this.extent);
  345. }
  346. /**
  347. * Returns the visible scale values of the layer
  348. *
  349. * @function getVisibleScales
  350. * @returns {Object} has properties .minScale and .maxScale
  351. */
  352. getVisibleScales () {
  353. // default layer, take from layer object
  354. // TODO do we need to handle a missing layer case?
  355. // no one should be calling this until layer is loaded anyways
  356. return {
  357. minScale: this._layer.minScale,
  358. maxScale: this._layer.maxScale
  359. };
  360. }
  361. /**
  362. * Returns the feature count
  363. *
  364. * @function getFeatureCount
  365. * @returns {Promise} resolves feature count
  366. */
  367. getFeatureCount () {
  368. // TODO determine best result to indicate that layer does not have features
  369. // we may want a null so that UI can display a different message (or suppress the message).
  370. // of note, the proxy is currently returning undefined for non-feature things
  371. return Promise.resolve(0);
  372. }
  373. /**
  374. * Create an extent centered around a point, that is appropriate for the current map scale.
  375. *
  376. * @function makeClickBuffer
  377. * @param {Object} point point on the map for extent center
  378. * @param {Object} map map object the extent is relevant for
  379. * @param {Integer} tolerance optional. distance in pixels from mouse point that qualifies as a hit. default is 5
  380. * @return {Object} an extent of desired size and location
  381. */
  382. makeClickBuffer (point, map, tolerance = 5) {
  383. // take pixel tolerance, convert to map units at current scale. x2 to turn radius into diameter
  384. const buffSize = 2 * tolerance * map.extent.getWidth() / map.width;
  385. // Build tolerance envelope of correct size
  386. const cBuff = this._apiRef.Map.Extent(0, 0, buffSize, buffSize, point.spatialReference);
  387. // move the envelope so it is centered around the point
  388. return cBuff.centerAt(point);
  389. }
  390. get symbology () { return this._featClasses[this._defaultFC].symbology; }
  391. /**
  392. * Indicates the layer is queryable.
  393. *
  394. * @function isQueryable
  395. * @returns {Boolean} the queryability of the layer
  396. */
  397. isQueryable () {
  398. return this._featClasses[this._defaultFC].queryable;
  399. }
  400. /**
  401. * Applies queryability to the layer.
  402. *
  403. * @function setQueryable
  404. * @param {Boolean} value the new queryability setting
  405. */
  406. setQueryable (value) {
  407. this._featClasses[this._defaultFC].queryable = value;
  408. }
  409. /**
  410. * Indicates the geometry type of the layer.
  411. *
  412. * @function getGeomType
  413. * @returns {String} the geometry type of the layer
  414. */
  415. getGeomType () {
  416. // standard case, layer has no geometry. This gets overridden in feature-based Record classes.
  417. return undefined;
  418. }
  419. /**
  420. * Provides the proxy interface object to the layer.
  421. *
  422. * @function getProxy
  423. * @returns {Object} the proxy interface for the layer
  424. */
  425. getProxy () {
  426. // NOTE baseclass used by things like WMSRecord, ImageRecord, TileRecord
  427. if (!this._rootProxy) {
  428. this._rootProxy = new layerInterface.LayerInterface(this, this.initialConfig.controls);
  429. this._rootProxy.convertToSingleLayer(this);
  430. }
  431. return this._rootProxy;
  432. }
  433. /**
  434. * Determines if layer's spatial ref matches the given spatial ref.
  435. * Mainly used to determine if a tile wont display on a map.
  436. * Highly recommend only calling this after a layer's load event has happened.
  437. * @param {Object} spatialReference spatial reference to compare against.
  438. * @return {Boolean} true if layer has same sr as input. false otherwise.
  439. */
  440. validateProjection (spatialReference) {
  441. if (spatialReference && this._layer && this._layer.spatialReference) {
  442. return this._apiRef.proj.isSpatialRefEqual(spatialReference, this._layer.spatialReference);
  443. } else {
  444. throw new error('validateProjection called -- essential info wasnt available');
  445. }
  446. }
  447. /**
  448. * Create a layer record with the appropriate geoApi layer type. Layer config
  449. * should be fully merged with all layer options defined (i.e. this constructor
  450. * will not apply any defaults).
  451. * @param {Object} layerClass the ESRI api object for the layer
  452. * @param {Object} apiRef object pointing to the geoApi. allows us to call other geoApi functions.
  453. * @param {Object} config layer config values
  454. * @param {Object} esriLayer an optional pre-constructed layer
  455. * @param {Function} epsgLookup an optional lookup function for EPSG codes (see geoService for signature)
  456. */
  457. constructor (layerClass, apiRef, config, esriLayer, epsgLookup) {
  458. super();
  459. this._layerClass = layerClass;
  460. this.name = config.name || '';
  461. this._featClasses = {};
  462. this._defaultFC = '0';
  463. this._apiRef = apiRef;
  464. this.initialConfig = config;
  465. this._stateListeners = [];
  466. this._hoverListeners = [];
  467. this._user = false;
  468. this._epsgLookup = epsgLookup;
  469. this.extent = config.extent; // if missing, will fill more values after layer loads
  470. // TODO verify we still use passthrough bindings.
  471. this._layerPassthroughBindings.forEach(bindingName =>
  472. this[bindingName] = (...args) => this._layer[bindingName](...args));
  473. this._layerPassthroughProperties.forEach(propName => {
  474. const descriptor = {
  475. enumerable: true,
  476. get: () => this._layer[propName]
  477. };
  478. Object.defineProperty(this, propName, descriptor);
  479. });
  480. if (esriLayer) {
  481. this.constructLayer = () => { throw new Error('Cannot construct pre-made layers'); };
  482. this._layer = esriLayer;
  483. this.bindEvents(this._layer);
  484. this._rootUrl = esriLayer.url || '';
  485. // TODO might want to change this to be whatever layer says it is
  486. this._state = shared.states.LOADING;
  487. if (!this.name) {
  488. // no name from config. attempt layer name
  489. this.name = esriLayer.name;
  490. }
  491. if (!esriLayer.url) {
  492. // file layer. force snapshot, force an onload
  493. this._snapshot = true;
  494. this.onLoad();
  495. }
  496. } else {
  497. this._rootUrl = config.url;
  498. this.constructLayer(config);
  499. this._state = shared.states.LOADING;
  500. }
  501. }
  502. }
  503. module.exports = () => ({
  504. LayerRecord
  505. });