attribute.js

  1. 'use strict';
  2. /*
  3. Structure and naming:
  4. this is a layer Package. it contains information about a single server-side layer.
  5. note this is not always 1-to-1 with client side. a client side DynamicLayer can have
  6. many server-side sublayers, each with their own attribute sets
  7. DO NOT access the ._attribData property directly, as it will not exist until the first
  8. request for attributes. use the function .getAttribs(), as it will properly handle the
  9. initial request, or return the previously loaded result (always as a promise)
  10. {
  11. "layerId": "<layerid>",
  12. "featureIdx": 3,
  13. "getAttribs": getAttribs(),
  14. "_attribData": Promise(
  15. <instance of a attribute data object, see below>
  16. ),
  17. "layerData": Promise(
  18. <instance of a layer data object, see below>
  19. )
  20. }
  21. this is an attribute data object. it resides in a promise (as the data needs to be downloaded)
  22. it contains the attribute data as an array, and an index mapping object id to array position
  23. {
  24. "features": [
  25. {
  26. "attributes": {
  27. "objectid": 23,
  28. "name": "Bruce",
  29. "age": 27
  30. }
  31. },
  32. ...
  33. ],
  34. "oidIndex": {
  35. "23": 0,
  36. ...
  37. }
  38. }
  39. this is a layer data object. it contains information describing the server-side layer
  40. {
  41. "fields: [
  42. {
  43. "name": "objectid",
  44. "type": "esriFieldTypeOID",
  45. "alias": "OBJECTID"
  46. },
  47. ...
  48. ],
  49. "oidField": "objectid",
  50. "renderer": {...},
  51. "geometryType": "esriGeometryPoint",
  52. "layerType": "Feature Layer",
  53. "minScale": 0,
  54. "maxScale": 0,
  55. "extent": {...}
  56. }
  57. */
  58. /**
  59. * Will generate an empty object structure to store attributes for a single layer of features
  60. * @private
  61. * @param {String} featureIdx server index of the layer
  62. * @param {Object} esriBundle bundle of API classes
  63. * @return {Object} empty layer package object
  64. */
  65. function newLayerPackage(featureIdx, esriBundle) {
  66. // only reason this is in a function is to tack on the lazy-load
  67. // attribute function. all object properties are added elsewhere
  68. const layerPackage = {
  69. featureIdx,
  70. getAttribs,
  71. loadedFeatureCount: 0,
  72. loadAbortFlag: false,
  73. loadIsDone: false,
  74. abortAttribLoad
  75. };
  76. /**
  77. * Return promise of attribute data object. First request triggers load
  78. * @private
  79. * @return {Promise} promise of attribute data object
  80. */
  81. function getAttribs() {
  82. if (layerPackage._attribData) {
  83. // attributes have already been downloaded.
  84. return layerPackage._attribData;
  85. }
  86. // first request for data. create the promise
  87. layerPackage.loadIsDone = false;
  88. layerPackage.loadAbortFlag = false;
  89. layerPackage.loadedFeatureCount = 0;
  90. layerPackage._attribData = new Promise((resolve, reject) => {
  91. // first wait for the layer specific data to finish loading
  92. // NOTE: by the time the application has access to getAttribs(), the .layerData
  93. // property will have been created.
  94. layerPackage.layerData.then(layerData => {
  95. // FIXME switch to native Promise
  96. const defFinished = new esriBundle.Deferred();
  97. const params = {
  98. maxId: -1,
  99. batchSize: -1,
  100. layerUrl: layerData.load.layerUrl,
  101. oidField: layerData.oidField,
  102. attribs: layerData.load.attribs,
  103. supportsLimit: layerData.load.supportsLimit,
  104. esriBundle,
  105. layerPackage
  106. };
  107. // begin the loading process
  108. loadDataBatch(params, defFinished);
  109. // after all data has been loaded
  110. defFinished.promise.then(features => {
  111. layerPackage.loadIsDone = true;
  112. // resolve the promise with the attribute set
  113. resolve(createAttribSet(layerData.oidField, features));
  114. }, error => {
  115. console.warn('error getting attribute data for ' + layerData.load.layerUrl);
  116. // attrib data deleted so the first check for attribData doesn't return a rejected promise
  117. delete layerPackage._attribData;
  118. reject(error);
  119. });
  120. });
  121. });
  122. return layerPackage._attribData;
  123. }
  124. /**
  125. * Attempts to stop an attribute load process. Will only abort if current load
  126. * is not the final batch of data.
  127. * @private
  128. */
  129. function abortAttribLoad() {
  130. layerPackage.loadAbortFlag = true;
  131. }
  132. return layerPackage;
  133. }
  134. /**
  135. * Will generate attribute package with object id indexes
  136. * @private
  137. * @param {String} oidField field containing object id
  138. * @param {Array} featureData feature objects to index and return
  139. * @return {Object} object containing features and an index by object id
  140. */
  141. function createAttribSet(oidField, featureData) {
  142. // add new data to layer data's array
  143. const res = {
  144. features: featureData,
  145. oidIndex: {}
  146. };
  147. // make index on object id
  148. featureData.forEach((elem, idx) => {
  149. // map object id to index of object in feature array
  150. // use toString, as objectid is integer and will act funny using array notation.
  151. res.oidIndex[elem.attributes[oidField].toString()] = idx;
  152. });
  153. return res;
  154. }
  155. // skim the last number off the Url
  156. // TODO apply more edge case tests to this function
  157. function getLayerIndex(layerUrl) {
  158. const re = /\/(\d+)\/?$/;
  159. const matches = layerUrl.match(re);
  160. if (matches) {
  161. return parseInt(matches[1]);
  162. }
  163. throw new Error('Cannot extract layer index from url ' + layerUrl);
  164. }
  165. /**
  166. * Recursive function to load a full set of attributes, regardless of the maximum output size of the service.
  167. * Passes result back on the provided Deferred object.
  168. *
  169. * @private
  170. * @param {Object} opts options object that consists of these properties
  171. * - maxId: integer, largest object id that has already been downloaded.
  172. * - supportsLimit: boolean, indicates if server result will notify us if our request surpassed the record limit.
  173. * - batchSize: integer, maximum number of results the service will return. if -1, means currently unknown. only required if supportsLimit is false.
  174. * - layerUrl: string, URL to feature layer endpoint.
  175. * - oidField: string, name of attribute containing the object id for the layer.
  176. * - attribs: string, a comma separated list of attributes to download. '*' will download all.
  177. * - esriBundle: object, standard set of ESRI API objects.
  178. * - layerPackage: reference to the object that manages the loaded attributes
  179. * @param {Object} callerDef deferred object that resolves when current data has been downloaded
  180. */
  181. function loadDataBatch(opts, callerDef) {
  182. if (opts.layerPackage.loadAbortFlag) {
  183. delete opts.layerPackage._attribData;
  184. opts.layerPackage.loadedFeatureCount = 0;
  185. callerDef.reject('ABORTED');
  186. return;
  187. }
  188. // fetch attributes from feature layer. where specifies records with id's higher than stuff already
  189. // downloaded. no geometry.
  190. // FIXME replace esriRequest with a library that handles proxies better
  191. const defData = opts.esriBundle.esriRequest({
  192. url: opts.layerUrl + '/query',
  193. content: {
  194. where: opts.oidField + '>' + opts.maxId,
  195. outFields: opts.attribs,
  196. returnGeometry: 'false',
  197. f: 'json',
  198. },
  199. callbackParamName: 'callback',
  200. handleAs: 'json'
  201. });
  202. defData.then(dataResult => {
  203. if (dataResult.features) {
  204. const len = dataResult.features.length;
  205. if (len > 0) {
  206. // figure out if we hit the end of the data. different logic for newer vs older servers.
  207. opts.layerPackage.loadedFeatureCount += len;
  208. let moreData;
  209. if (opts.supportsLimit) {
  210. moreData = dataResult.exceededTransferLimit;
  211. } else {
  212. if (opts.batchSize === -1) {
  213. // this is our first batch. set the max batch size to this batch size
  214. opts.batchSize = len;
  215. }
  216. moreData = (len >= opts.batchSize);
  217. }
  218. if (moreData) {
  219. // stash the result and call the service again for the next batch of data.
  220. // max id becomes last object id in the current batch
  221. const thisDef = new opts.esriBundle.Deferred();
  222. opts.maxId = dataResult.features[len - 1].attributes[opts.oidField];
  223. loadDataBatch(opts, thisDef);
  224. thisDef.then(dataArray => {
  225. // chain the next result to our current result, then pass back to caller
  226. callerDef.resolve(dataResult.features.concat(dataArray));
  227. },
  228. error => {
  229. callerDef.reject(error);
  230. });
  231. } else {
  232. // done thanks
  233. callerDef.resolve(dataResult.features);
  234. }
  235. } else {
  236. // no more data. we are done
  237. callerDef.resolve([]);
  238. }
  239. } else {
  240. // it is possible to have an error, but it comes back on the "success" channel.
  241. callerDef.reject(dataResult.error);
  242. }
  243. },
  244. error => {
  245. callerDef.reject(error);
  246. });
  247. }
  248. function loadServerAttribsBuilder(esriBundle, geoApi) {
  249. /**
  250. * fetch attributes from an ESRI ArcGIS Server Feature Layer Service endpoint
  251. * @param {String} mapServiceUrl an arcgis map server service endpoint (no integer index)
  252. * @param {String} featureIdx index of where the endpoint is.
  253. * @param {String} attribs an optional comma separated list of attributes to download. default '*' will download all
  254. * @return {Object} attributes in a packaged format for asynch access
  255. */
  256. return (mapServiceUrl, featureIdx, attribs = '*') => {
  257. const layerUrl = mapServiceUrl + '/' + featureIdx;
  258. const layerPackage = newLayerPackage(featureIdx, esriBundle);
  259. // get information about this layer, asynch
  260. layerPackage.layerData = new Promise((resolve, reject) => {
  261. const layerData = {};
  262. // extract info for this service
  263. const defService = esriBundle.esriRequest({
  264. url: layerUrl,
  265. content: { f: 'json' },
  266. callbackParamName: 'callback',
  267. handleAs: 'json',
  268. });
  269. defService.then(serviceResult => {
  270. if (serviceResult && (typeof serviceResult.error === 'undefined')) {
  271. // properties for all endpoints
  272. layerData.layerType = serviceResult.type;
  273. layerData.geometryType = serviceResult.geometryType || 'none'; // TODO need to decide what propert default is. Raster Layer has null gt.
  274. layerData.minScale = serviceResult.effectiveMinScale || serviceResult.minScale;
  275. layerData.maxScale = serviceResult.effectiveMaxScale || serviceResult.maxScale;
  276. layerData.supportsFeatures = false; // saves us from having to keep comparing type to 'Feature Layer' on the client
  277. layerData.extent = serviceResult.extent;
  278. if (serviceResult.type === 'Feature Layer') {
  279. layerData.supportsFeatures = true;
  280. layerData.fields = serviceResult.fields;
  281. layerData.nameField = serviceResult.displayField;
  282. // find object id field
  283. // NOTE cannot use arrow functions here due to bug
  284. const noFieldDefOid = serviceResult.fields.every(function (elem) {
  285. if (elem.type === 'esriFieldTypeOID') {
  286. layerData.oidField = elem.name;
  287. return false; // break the loop
  288. }
  289. return true; // keep looping
  290. });
  291. if (noFieldDefOid) {
  292. // we encountered a service that does not mark a field as the object id.
  293. // attempt to use alternate definition. if neither exists, we are toast.
  294. layerData.oidField = serviceResult.objectIdField ||
  295. console.error(`Encountered service with no OID defined: ${layerUrl}`);
  296. }
  297. // ensure our attribute list contains the object id
  298. if (attribs !== '*') {
  299. if (attribs.split(',').indexOf(layerData.oidField) === -1) {
  300. attribs += (',' + layerData.oidField);
  301. }
  302. }
  303. // add renderer and legend
  304. layerData.renderer = geoApi.symbology.cleanRenderer(serviceResult.drawingInfo.renderer,
  305. serviceResult.fields);
  306. layerData.legend = geoApi.symbology.rendererToLegend(layerData.renderer, featureIdx,
  307. serviceResult.fields);
  308. geoApi.symbology.enhanceRenderer(layerData.renderer, layerData.legend);
  309. // temporarily store things for delayed attributes
  310. layerData.load = {
  311. // version number is only provided on 10.0 SP1 servers and up.
  312. // servers 10.1 and higher support the query limit flag
  313. supportsLimit: (serviceResult.currentVersion || 1) >= 10.1,
  314. layerUrl,
  315. attribs
  316. };
  317. }
  318. // return the layer data promise result
  319. resolve(layerData);
  320. } else {
  321. // case where error happened but service request was successful
  322. console.warn('Service metadata load error');
  323. if (serviceResult && serviceResult.error) {
  324. // reject with error
  325. reject(serviceResult.error);
  326. } else {
  327. reject(new Error('Unknown error loading service metadata'));
  328. }
  329. }
  330. }, error => {
  331. // failed to load service info. reject with error
  332. console.warn('Service metadata load error : ' + error);
  333. reject(error);
  334. });
  335. });
  336. return layerPackage;
  337. };
  338. }
  339. function loadFileAttribsBuilder(esriBundle, geoApi) {
  340. return layer => {
  341. // feature layer was loaded from a file.
  342. // this approach is inefficient (duplicates attributes in layer and in attribute store),
  343. // but provides a consistent approach to attributes regardless of where the layer came from
  344. const layerPackage = newLayerPackage('0', esriBundle); // files have no index (no server), so we use value 0
  345. // it's local, no need to lazy-load
  346. layerPackage._attribData = Promise.resolve(createAttribSet(layer.objectIdField, layer.graphics.map(elem => {
  347. return { attributes: elem.attributes };
  348. })));
  349. const renderer = layer.renderer.toJson();
  350. const legend = geoApi.symbology.rendererToLegend(renderer, 0);
  351. geoApi.symbology.enhanceRenderer(renderer, legend);
  352. // TODO revisit the geometry type. ideally, fix our GeoJSON to Feature to populate the property
  353. layerPackage.layerData = Promise.resolve({
  354. oidField: layer.objectIdField,
  355. fields: layer.fields,
  356. geometryType: layer.geometryType || JSON.parse(layer._json).layerDefinition.drawingInfo.geometryType,
  357. minScale: layer.minScale,
  358. maxScale: layer.maxScale,
  359. layerType: 'Feature Layer',
  360. renderer,
  361. legend
  362. });
  363. return layerPackage;
  364. };
  365. }
  366. // Attribute Loader related functions
  367. // TODO consider re-writing all the asynch stuff with the ECMA-7 style of asynch keywords
  368. module.exports = (esriBundle, geoApi) => {
  369. return {
  370. loadServerAttribs: loadServerAttribsBuilder(esriBundle, geoApi),
  371. loadFileAttribs: loadFileAttribsBuilder(esriBundle, geoApi),
  372. getLayerIndex
  373. };
  374. };