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