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. // TODO should probably remove passthrough bindings?
  25. get _layerPassthroughBindings () { return ['setOpacity', 'setVisibility']; } // TODO when jshint parses instance fields properly we can change this from a property to a field
  26. get _layerPassthroughProperties () { return ['visibleAtMapScale', 'visible', 'spatialReference']; } // TODO when jshint parses instance fields properly we can change this from a property to a field
  27. get userLayer () { return this._user; } // indicates if layer was added by a user
  28. set userLayer (value) { this._user = value; }
  29. get visibility () {
  30. if (this._layer) {
  31. return this._layer.visible;
  32. } else {
  33. return true; // TODO what should a proper default be? example of this situation??
  34. }
  35. }
  36. set visibility (value) {
  37. if (this._layer) {
  38. this._layer.setVisibility(value);
  39. }
  40. // TODO do we need an ELSE case here?
  41. }
  42. get opacity () {
  43. if (this._layer) {
  44. return this._layer.opacity;
  45. } else {
  46. return 1; // TODO what should a proper default be? example of this situation??
  47. }
  48. }
  49. set opacity (value) {
  50. if (this._layer) {
  51. this._layer.setOpacity(value);
  52. }
  53. // TODO do we need an ELSE case here?
  54. }
  55. /**
  56. * Attach event handlers to layer events
  57. */
  58. bindEvents (layer) {
  59. // TODO optional refactor. Rather than making the events object in the parameter,
  60. // do it as a variable, and only add mouse-over, mouse-out events if we are
  61. // in an app configuration that will use it. May save a bit of processing
  62. // by not having unused events being handled and ignored.
  63. // Second optional thing. Call a separate wrapEvents in FeatuerRecord class
  64. // TODO apply johann update here
  65. this._apiRef.events.wrapEvents(layer, {
  66. // wrapping the function calls to keep `this` bound correctly
  67. load: () => this.onLoad(),
  68. error: e => this.onError(e),
  69. 'update-start': () => this.onUpdateStart(),
  70. 'update-end': () => this.onUpdateEnd(),
  71. 'mouse-over': e => this.onMouseOver(e),
  72. 'mouse-out': e => this.onMouseOut(e)
  73. });
  74. }
  75. /**
  76. * Perform layer initialization tasks
  77. */
  78. constructLayer () {
  79. this._layer = this.layerClass(this.config.url, this.makeLayerConfig());
  80. this.bindEvents(this._layer);
  81. return this._layer;
  82. }
  83. /**
  84. * Handle a change in layer state
  85. */
  86. _stateChange (newState) {
  87. this._state = newState;
  88. console.log(`State change for ${this.layerId} to ${newState}`);
  89. // if we don't copy the array we could be looping on an array
  90. // that is being modified as it is being read
  91. this._fireEvent(this._stateListeners, this._state);
  92. }
  93. /**
  94. * Wire up state change listener
  95. */
  96. addStateListener (listenerCallback) {
  97. this._stateListeners.push(listenerCallback);
  98. return listenerCallback;
  99. }
  100. /**
  101. * Remove a state change listener
  102. */
  103. removeStateListener (listenerCallback) {
  104. const idx = this._stateListeners.indexOf(listenerCallback);
  105. if (idx < 0) {
  106. throw new Error('Attempting to remove a listener which is not registered.');
  107. }
  108. this._stateListeners.splice(idx, 1);
  109. }
  110. /**
  111. * Wire up mouse hover listener
  112. */
  113. addHoverListener (listenerCallback) {
  114. this._hoverListeners.push(listenerCallback);
  115. return listenerCallback;
  116. }
  117. /**
  118. * Remove a mouse hover listener
  119. */
  120. removeHoverListener (listenerCallback) {
  121. const idx = this._hoverListeners.indexOf(listenerCallback);
  122. if (idx < 0) {
  123. throw new Error('Attempting to remove a listener which is not registered.');
  124. }
  125. this._hoverListeners.splice(idx, 1);
  126. }
  127. /**
  128. * Triggers when the layer loads.
  129. * Returns an array of promises that need to resolve for layer to be loaded.
  130. *
  131. * @function onLoad
  132. */
  133. onLoad () {
  134. // only super-general stuff in here, that all layers should run.
  135. console.info(`Layer loaded: ${this._layer.id}`);
  136. if (!this.name) {
  137. // no name from config. attempt layer name
  138. this.name = this._layer.name;
  139. }
  140. if (!this.extent) {
  141. // no extent from config. attempt layer extent
  142. this.extent = this._layer.fullExtent;
  143. }
  144. let lookupPromise = Promise.resolve();
  145. if (this._epsgLookup) {
  146. const check = this._apiRef.proj.checkProj(this.spatialReference, this._epsgLookup);
  147. if (check.lookupPromise) {
  148. lookupPromise = check.lookupPromise;
  149. }
  150. // TODO if we don't find a projection, the app will show the layer loading forever.
  151. // might need to handle the fail case and show something to the user.
  152. }
  153. return [lookupPromise];
  154. }
  155. /**
  156. * Handles when the layer has an error
  157. */
  158. onError (e) {
  159. console.warn(`Layer error: ${e}`);
  160. console.warn(e);
  161. this._stateChange(shared.states.ERROR);
  162. }
  163. /**
  164. * Handles when the layer starts to update
  165. */
  166. onUpdateStart () {
  167. this._stateChange(shared.states.REFRESH);
  168. }
  169. /**
  170. * Handles when the layer finishes updating
  171. */
  172. onUpdateEnd () {
  173. this._stateChange(shared.states.LOADED);
  174. }
  175. /**
  176. * Handles when the mouse enters a layer
  177. */
  178. onMouseOver () {
  179. // do nothing in baseclass
  180. }
  181. /**
  182. * Handles when the mouse leaves a layer
  183. */
  184. onMouseOut () {
  185. // do nothing in baseclass
  186. }
  187. /**
  188. * Creates an options object for the physical layer
  189. */
  190. makeLayerConfig () {
  191. return {
  192. id: this.config.id,
  193. opacity: this.config.state.opacity,
  194. visible: this.config.state.visibility
  195. };
  196. }
  197. /**
  198. * Figure out visibility scale. Will use layer minScale/maxScale
  199. * and map levels of detail to determine scale boundaries.
  200. *
  201. * @param {Array} lods array of valid levels of detail for the map
  202. * @param {Object} scaleSet contains .minScale and .maxScale for valid viewing scales
  203. * @param {Boolean} zoomIn the zoom to scale direction; true need to zoom in; false need to zoom out
  204. * @param {Boolean} zoomGraphic an optional value when zoomToScale is use to zoom to a graphic element;
  205. * true used to zoom to a graphic element; false not used to zoom to a graphic element
  206. * @returns {Object} a level of detail (lod) object for the appropriate scale to zoom to
  207. */
  208. findZoomScale (lods, scaleSet, zoomIn, zoomGraphic = false) {
  209. // TODO rename function to getZoomScale?
  210. // TODO take a second look at parameters zoomIn and zoomGraphic. how are they derived (in the caller code)?
  211. // seems weird to me to do it this way
  212. // TODO naming of "zoomIn" is very misleading and confusing. in practice, we are often
  213. // setting the value to false when we are zooming down close to the ground.
  214. // Need full analysis of usage, possibly rename parameter or update param docs.
  215. // TODO update function parameters once things are working
  216. // if the function is used to zoom to a graphic element and the layer is out of scale we always want
  217. // the layer to zoom to the maximum scale allowed for the layer. In this case, zoomIn must be
  218. // always false
  219. zoomIn = (zoomGraphic) ? false : zoomIn;
  220. // TODO double-check where lods are coming from in old code
  221. // change search order of lods depending if we are zooming in or out
  222. const modLods = zoomIn ? lods : [...lods].reverse();
  223. return modLods.find(currentLod => zoomIn ? currentLod.scale < scaleSet.minScale :
  224. currentLod.scale > scaleSet.maxScale);
  225. }
  226. /**
  227. * Set map scale depending on zooming in or zooming out of layer visibility scale
  228. *
  229. * @param {Object} map layer to zoom to scale to for feature layers; parent layer for dynamic layers
  230. * @param {Object} lod scale object the map will be set to
  231. * @param {Boolean} zoomIn the zoom to scale direction; true need to zoom in; false need to zoom out
  232. * @returns {Promise} resolves after map is done changing its extent
  233. */
  234. setMapScale (map, lod, zoomIn) {
  235. // TODO possible this would live in the map manager in a bigger refactor.
  236. // NOTE because we utilize the layer object's full extent (and not child feature class extents),
  237. // this function stays in this class.
  238. // if zoom in is needed; must find center of layer's full extent and perform center&zoom
  239. if (zoomIn) {
  240. // need to reproject in case full extent in a different sr than basemap
  241. const gextent = this._apiRef.proj.localProjectExtent(this._layer.fullExtent, map.spatialReference);
  242. const reprojLayerFullExt = this._apiRef.mapManager.Extent(gextent.x0, gextent.y0,
  243. gextent.x1, gextent.y1, gextent.sr);
  244. // check if current map extent already in layer extent
  245. return map.setScale(lod.scale).then(() => {
  246. // if map extent not in layer extent, zoom to center of layer extent
  247. // don't need to return Deferred otherwise because setScale already resolved here
  248. if (!reprojLayerFullExt.intersects(map.extent)) {
  249. return map.centerAt(reprojLayerFullExt.getCenter());
  250. }
  251. });
  252. } else {
  253. return map.setScale(lod.scale);
  254. }
  255. }
  256. /**
  257. * Figure out visibility scale and zoom to it. Will use layer minScale/maxScale
  258. * and map levels of detail to determine scale boundaries.
  259. *
  260. * @private
  261. * @param {Object} map the map object
  262. * @param {Array} lods level of details array for basemap
  263. * @param {Boolean} zoomIn the zoom to scale direction; true need to zoom in; false need to zoom out
  264. * @param {Object} scaleSet contains min and max scales for the layer.
  265. * @param {Boolean} zoomGraphic an optional value when zoomToScale is use to zoom to a graphic element;
  266. * true used to zoom to a graphic element; false not used to zoom to a graphic element
  267. */
  268. _zoomToScaleSet (map, lods, zoomIn, scaleSet, zoomGraphic = false) {
  269. // TODO update function parameters once things are working
  270. // if the function is used to zoom to a graphic element and the layer is out of scale we always want
  271. // the layer to zoom to the maximum scale allowed for the layer. In this case, zoomIn must be
  272. // always false
  273. zoomIn = (zoomGraphic) ? false : zoomIn;
  274. // NOTE we use lods provided by config rather that system-ish map.__tileInfo.lods
  275. const zoomLod = this.findZoomScale(lods, scaleSet, zoomIn, zoomGraphic = false);
  276. // TODO ponder on the implementation of this
  277. return this.setMapScale(this._layer, zoomLod, zoomIn);
  278. }
  279. // TODO docs
  280. zoomToScale (map, lods, zoomIn, zoomGraphic = false) {
  281. // get scale set from child, then execute zoom
  282. const scaleSet = this._featClasses[this._defaultFC].getScaleSet();
  283. return this._zoomToScaleSet(map, lods, zoomIn, scaleSet, zoomGraphic);
  284. }
  285. // TODO docs
  286. isOffScale (mapScale) {
  287. return this._featClasses[this._defaultFC].isOffScale(mapScale);
  288. }
  289. /**
  290. * Zoom to layer boundary of the layer specified by layerId
  291. * @param {Object} map map object we want to execute the zoom on
  292. * @return {Promise} resolves when map is done zooming
  293. */
  294. zoomToBoundary (map) {
  295. return this.zoomToExtent(map, this.extent);
  296. }
  297. /**
  298. * Worker function to zoom the map to an extent of possibly
  299. * @param {Object} map map object we want to execute the zoom on
  300. * @param {Object} extent map object we want to execute the zoom on
  301. * @private
  302. * @return {Promise} resolves when map is done zooming
  303. */
  304. zoomToExtent (map, extent) {
  305. // TODO add some caching? make sure it will get wiped if we end up changing projections
  306. // or use wkid as caching key?
  307. // trickyier now that we are in shared function.
  308. // maybe return an object {promise, projected extent}, and caller can cache it?
  309. const projRawExtent = this._apiRef.proj.localProjectExtent(extent, map.spatialReference);
  310. const projFancyExtent = this._apiRef.mapManager.Extent(projRawExtent.x0, projRawExtent.y0,
  311. projRawExtent.x1, projRawExtent.y1, projRawExtent.sr);
  312. return map.setExtent(projFancyExtent);
  313. }
  314. /**
  315. * Returns the visible scale values of the layer
  316. * @returns {Object} has properties .minScale and .maxScale
  317. */
  318. getVisibleScales () {
  319. // default layer, take from layer object
  320. // TODO do we need to handle a missing layer case?
  321. // no one should be calling this until layer is loaded anyways
  322. return {
  323. minScale: this._layer.minScale,
  324. maxScale: this._layer.maxScale
  325. };
  326. }
  327. /**
  328. * Returns the feature count
  329. * @returns {Promise} resolves feature count
  330. */
  331. getFeatureCount () {
  332. // TODO determine best result to indicate that layer does not have features
  333. // we may want a null so that UI can display a different message (or suppress the message).
  334. // of note, the proxy is currently returning undefined for non-feature things
  335. return Promise.resolve(0);
  336. }
  337. /**
  338. * Create an extent centered around a point, that is appropriate for the current map scale.
  339. * @param {Object} point point on the map for extent center
  340. * @param {Object} map map object the extent is relevant for
  341. * @param {Integer} tolerance optional. distance in pixels from mouse point that qualifies as a hit. default is 5
  342. * @return {Object} an extent of desired size and location
  343. */
  344. makeClickBuffer (point, map, tolerance = 5) {
  345. // take pixel tolerance, convert to map units at current scale. x2 to turn radius into diameter
  346. const buffSize = 2 * tolerance * map.extent.getWidth() / map.width;
  347. // Build tolerance envelope of correct size
  348. const cBuff = new this._apiRef.mapManager.Extent(0, 0, buffSize, buffSize, point.spatialReference);
  349. // move the envelope so it is centered around the point
  350. return cBuff.centerAt(point);
  351. }
  352. // TODO docs
  353. get symbology () { return this._featClasses[this._defaultFC].symbology; }
  354. // TODO docs
  355. isQueryable () {
  356. return this._featClasses[this._defaultFC].queryable;
  357. }
  358. // TODO docs
  359. setQueryable (value) {
  360. this._featClasses[this._defaultFC].queryable = value;
  361. }
  362. getGeomType () {
  363. // standard case, layer has no geometry. This gets overridden in feature-based Record classes.
  364. return undefined;
  365. }
  366. // returns the proxy interface object for the root of the layer (i.e. main entry in legend, not nested child things)
  367. // TODO docs
  368. getProxy () {
  369. // TODO figure out control name arrays from config (specifically, disabled list)
  370. // updated config schema uses term "enabled" but have a feeling it really means available
  371. // TODO figure out how placeholders work with all this
  372. // TODO does this even make sense in the baseclass anymore? Everything *should* be overriding this.
  373. if (!this._rootProxy) {
  374. this._rootProxy = new layerInterface.LayerInterface(this, this.initialConfig.controls);
  375. this._rootProxy.convertToSingleLayer(this);
  376. }
  377. return this._rootProxy;
  378. }
  379. /**
  380. * Create a layer record with the appropriate geoApi layer type. Layer config
  381. * should be fully merged with all layer options defined (i.e. this constructor
  382. * will not apply any defaults).
  383. * @param {Object} layerClass the ESRI api object for the layer
  384. * @param {Object} apiRef object pointing to the geoApi. allows us to call other geoApi functions.
  385. * @param {Object} config layer config values
  386. * @param {Object} esriLayer an optional pre-constructed layer
  387. * @param {Function} epsgLookup an optional lookup function for EPSG codes (see geoService for signature)
  388. */
  389. constructor (layerClass, apiRef, config, esriLayer, epsgLookup) {
  390. super();
  391. this._layerClass = layerClass;
  392. this.name = config.name || '';
  393. this._featClasses = {};
  394. this._defaultFC = '0';
  395. this._apiRef = apiRef;
  396. this.initialConfig = config;
  397. this._stateListeners = [];
  398. this._hoverListeners = [];
  399. this._user = false;
  400. this._epsgLookup = epsgLookup;
  401. this.extent = config.extent; // if missing, will fill more values after layer loads
  402. // TODO verify we still use passthrough bindings.
  403. this._layerPassthroughBindings.forEach(bindingName =>
  404. this[bindingName] = (...args) => this._layer[bindingName](...args));
  405. this._layerPassthroughProperties.forEach(propName => {
  406. const descriptor = {
  407. enumerable: true,
  408. get: () => this._layer[propName]
  409. };
  410. Object.defineProperty(this, propName, descriptor);
  411. });
  412. if (esriLayer) {
  413. this.constructLayer = () => { throw new Error('Cannot construct pre-made layers'); };
  414. this._layer = esriLayer;
  415. this.bindEvents(this._layer);
  416. // TODO might want to change this to be whatever layer says it is
  417. this._state = shared.states.LOADING;
  418. if (!this.name) {
  419. // no name from config. attempt layer name
  420. this.name = esriLayer.name;
  421. }
  422. if (!esriLayer.url) {
  423. // file layer. force snapshot, force an onload
  424. this._snapshot = true;
  425. this.onLoad();
  426. }
  427. } else {
  428. this.constructLayer(config);
  429. this._state = shared.states.LOADING;
  430. }
  431. }
  432. }
  433. module.exports = () => ({
  434. LayerRecord
  435. });