attribute.js

  1. 'use strict';
  2. // TODO consider refactoring this file so that the geoApi object is passed in along with the
  3. // esriBundle, then reference the shared module from it. See layer.js as example.
  4. const shared = require('./shared.js');
  5. /*
  6. Structure and naming:
  7. this is the Bundle. it is the topmost object in the structure.
  8. it packages up attributes for an entire layer object (i.e. FeatureLayer, DynamicLayer)
  9. {
  10. layerId: <layerId for layer>,
  11. indexes: ["6", "7"],
  12. "6": {
  13. <instance of a layer package, see below>
  14. },
  15. "7": {
  16. <instance of a layer package, see below>
  17. }
  18. }
  19. this is a layer Package. it contains information about a single server-side layer.
  20. note this is not always 1-to-1 with client side. a client side DynamicLayer can have
  21. many server-side sublayers, each with their own attribute sets
  22. DO NOT access the ._attribData property directly, as it will not exist until the first
  23. request for attributes. use the function .getAttribs(), as it will properly handle the
  24. initial request, or return the previously loaded result (always as a promise)
  25. {
  26. "layerId": "<layerid>",
  27. "featureIdx": 3,
  28. "getAttribs": getAttribs(),
  29. "_attribData": Promise(
  30. <instance of a attribute data object, see below>
  31. ),
  32. "layerData": Promise(
  33. <instance of a layer data object, see below>
  34. )
  35. }
  36. this is an attribute data object. it resides in a promise (as the data needs to be downloaded)
  37. it contains the attribute data as an array, and an index mapping object id to array position
  38. {
  39. "features": [
  40. {
  41. "attributes": {
  42. "objectid": 23,
  43. "name": "Bruce",
  44. "age": 27
  45. }
  46. },
  47. ...
  48. ],
  49. "oidIndex": {
  50. "23": 0,
  51. ...
  52. }
  53. }
  54. this is a layer data object. it contains information describing the server-side layer
  55. {
  56. "fields: [
  57. {
  58. "name": "objectid",
  59. "type": "esriFieldTypeOID",
  60. "alias": "OBJECTID"
  61. },
  62. ...
  63. ],
  64. "oidField": "objectid",
  65. "renderer": {...},
  66. "geometryType": "esriGeometryPoint",
  67. "layerType": "Feature Layer",
  68. "minScale": 0,
  69. "maxScale": 0,
  70. "extent": {...}
  71. }
  72. */
  73. /**
  74. * Will generate an empty object structure to store a bundle of attributes for a full layer
  75. * @private
  76. * @return {Object} empty layer bundle object
  77. */
  78. function newLayerBundle(layerId) {
  79. const bundle = {
  80. layerId, // for easy access to know what layer the results belong to
  81. indexes: [], // for easy iteration over all indexes in the set
  82. registerData
  83. };
  84. function registerData(layerPackage) {
  85. layerPackage.layerId = bundle.layerId; // layerPackage is unaware of layerId. assign it during registration
  86. bundle[layerPackage.featureIdx.toString()] = layerPackage;
  87. bundle.indexes.push(layerPackage.featureIdx.toString());
  88. }
  89. return bundle;
  90. }
  91. /**
  92. * Will generate an empty object structure to store attributes for a single layer of features
  93. * @private
  94. * @param {Integer} featureIdx server index of the layer
  95. * @param {Object} esriBundle bundle of API classes
  96. * @return {Object} empty layer package object
  97. */
  98. function newLayerPackage(featureIdx, esriBundle) {
  99. // only reason this is in a function is to tack on the lazy-load
  100. // attribute function. all object properties are added elsewhere
  101. const layerPackage = {
  102. featureIdx,
  103. getAttribs
  104. };
  105. /**
  106. * Return promise of attribute data object. First request triggers load
  107. * @private
  108. * @return {Promise} promise of attribute data object
  109. */
  110. function getAttribs() {
  111. if (layerPackage._attribData) {
  112. // attributes have already been downloaded.
  113. return layerPackage._attribData;
  114. }
  115. // first request for data. create the promise
  116. layerPackage._attribData = new Promise((resolve, reject) => {
  117. // first wait for the layer specific data to finish loading
  118. // NOTE: by the time the application has access to getAttribs(), the .layerData
  119. // property will have been created.
  120. layerPackage.layerData.then(layerData => {
  121. // FIXME switch to native Promise
  122. const defFinished = new esriBundle.Deferred();
  123. const params = {
  124. maxId: -1,
  125. batchSize: -1,
  126. layerUrl: layerData.load.layerUrl,
  127. oidField: layerData.oidField,
  128. attribs: layerData.load.attribs,
  129. supportsLimit: layerData.load.supportsLimit,
  130. esriBundle
  131. };
  132. // begin the loading process
  133. loadDataBatch(params, defFinished);
  134. // after all data has been loaded
  135. defFinished.promise.then(features => {
  136. delete layerData.load; // no longer need this info
  137. // resolve the promise with the attribute set
  138. resolve(createAttribSet(layerData.oidField, features));
  139. }, error => {
  140. console.warn('error getting attribute data for ' + layerData.load.layerUrl);
  141. // attrib data deleted so the first check for attribData doesn't return a rejected promise
  142. delete layerPackage._attribData;
  143. reject(error);
  144. });
  145. });
  146. });
  147. return layerPackage._attribData;
  148. }
  149. return layerPackage;
  150. }
  151. /**
  152. * Will generate attribute package with object id indexes
  153. * @private
  154. * @param {String} oidField field containing object id
  155. * @param {Array} featureData feature objects to index and return
  156. * @return {Object} object containing features and an index by object id
  157. */
  158. function createAttribSet(oidField, featureData) {
  159. // add new data to layer data's array
  160. const res = {
  161. features: featureData,
  162. oidIndex: {}
  163. };
  164. // make index on object id
  165. featureData.forEach((elem, idx) => {
  166. // map object id to index of object in feature array
  167. // use toString, as objectid is integer and will act funny using array notation.
  168. res.oidIndex[elem.attributes[oidField].toString()] = idx;
  169. });
  170. return res;
  171. }
  172. // skim the last number off the Url
  173. // TODO apply more edge case tests to this function
  174. function getLayerIndex(layerUrl) {
  175. const re = /\/(\d+)\/?$/;
  176. const matches = layerUrl.match(re);
  177. if (matches) {
  178. return parseInt(matches[1]);
  179. }
  180. throw new Error('Cannot extract layer index from url ' + layerUrl);
  181. }
  182. /**
  183. * Recursive function to load a full set of attributes, regardless of the maximum output size of the service.
  184. * Passes result back on the provided Deferred object.
  185. *
  186. * @private
  187. * @param {Object} opts options object that consists of these properties
  188. * - maxId: integer, largest object id that has already been downloaded.
  189. * - supportsLimit: boolean, indicates if server result will notify us if our request surpassed the record limit.
  190. * - batchSize: integer, maximum number of results the service will return. if -1, means currently unknown. only required if supportsLimit is false.
  191. * - layerUrl: string, URL to feature layer endpoint.
  192. * - oidField: string, name of attribute containing the object id for the layer.
  193. * - attribs: string, a comma separated list of attributes to download. '*' will download all.
  194. * - esriBundle: object, standard set of ESRI API objects.
  195. * @param {Object} callerDef deferred object that resolves when current data has been downloaded
  196. */
  197. function loadDataBatch(opts, callerDef) {
  198. // fetch attributes from feature layer. where specifies records with id's higher than stuff already
  199. // downloaded. no geometry.
  200. // FIXME replace esriRequest with a library that handles proxies better
  201. const defData = opts.esriBundle.esriRequest({
  202. url: opts.layerUrl + '/query',
  203. content: {
  204. where: opts.oidField + '>' + opts.maxId,
  205. outFields: opts.attribs,
  206. returnGeometry: 'false',
  207. f: 'json',
  208. },
  209. callbackParamName: 'callback',
  210. handleAs: 'json'
  211. });
  212. defData.then(dataResult => {
  213. if (dataResult.features) {
  214. const len = dataResult.features.length;
  215. if (len > 0) {
  216. // figure out if we hit the end of the data. different logic for newer vs older servers.
  217. let moreData;
  218. if (opts.supportsLimit) {
  219. moreData = dataResult.exceededTransferLimit;
  220. } else {
  221. if (opts.batchSize === -1) {
  222. // this is our first batch. set the max batch size to this batch size
  223. opts.batchSize = len;
  224. }
  225. moreData = (len >= opts.batchSize);
  226. }
  227. if (moreData) {
  228. // stash the result and call the service again for the next batch of data.
  229. // max id becomes last object id in the current batch
  230. const thisDef = new opts.esriBundle.Deferred();
  231. opts.maxId = dataResult.features[len - 1].attributes[opts.oidField];
  232. loadDataBatch(opts, thisDef);
  233. thisDef.then(dataArray => {
  234. // chain the next result to our current result, then pass back to caller
  235. callerDef.resolve(dataResult.features.concat(dataArray));
  236. },
  237. error => {
  238. callerDef.reject(error);
  239. });
  240. } else {
  241. // done thanks
  242. callerDef.resolve(dataResult.features);
  243. }
  244. } else {
  245. // no more data. we are done
  246. callerDef.resolve([]);
  247. }
  248. } else {
  249. // it is possible to have an error, but it comes back on the "success" channel.
  250. callerDef.reject(dataResult.error);
  251. }
  252. },
  253. error => {
  254. callerDef.reject(error);
  255. });
  256. }
  257. /**
  258. * fetch attributes from an ESRI ArcGIS Server Feature Layer Service endpoint
  259. * @param {String} layerUrl an arcgis feature layer service endpoint
  260. * @param {Integer} featureIdx index of where the endpoint is. used for legend output
  261. * @param {String} attribs a comma separated list of attributes to download. '*' will download all
  262. * @param {Object} esriBundle bundle of API classes
  263. * @return {Object} attributes in a packaged format for asynch access
  264. */
  265. function loadFeatureAttribs(layerUrl, featureIdx, attribs, esriBundle, geoApi) {
  266. const layerPackage = newLayerPackage(getLayerIndex(layerUrl), esriBundle);
  267. // get information about this layer, asynch
  268. layerPackage.layerData = new Promise((resolve, reject) => {
  269. const layerData = {};
  270. // extract info for this service
  271. const defService = esriBundle.esriRequest({
  272. url: layerUrl,
  273. content: { f: 'json' },
  274. callbackParamName: 'callback',
  275. handleAs: 'json',
  276. });
  277. defService.then(serviceResult => {
  278. if (serviceResult && (typeof serviceResult.error === 'undefined')) {
  279. // properties for all endpoints
  280. layerData.layerType = serviceResult.type;
  281. layerData.geometryType = serviceResult.geometryType || 'none'; // TODO need to decide what propert default is. Raster Layer has null gt.
  282. layerData.minScale = serviceResult.minScale;
  283. layerData.maxScale = serviceResult.maxScale;
  284. layerData.supportsFeatures = false; // saves us from having to keep comparing type to 'Feature Layer' on the client
  285. layerData.extent = serviceResult.extent;
  286. if (serviceResult.type === 'Feature Layer') {
  287. layerData.supportsFeatures = true;
  288. layerData.fields = serviceResult.fields;
  289. // find object id field
  290. // NOTE cannot use arrow functions here due to bug
  291. serviceResult.fields.every(function (elem) {
  292. if (elem.type === 'esriFieldTypeOID') {
  293. layerData.oidField = elem.name;
  294. return false; // break the loop
  295. }
  296. return true; // keep looping
  297. });
  298. // ensure our attribute list contains the object id
  299. if (attribs !== '*') {
  300. if (attribs.split(',').indexOf(layerData.oidField) === -1) {
  301. attribs += (',' + layerData.oidField);
  302. }
  303. }
  304. // add renderer and legend
  305. layerData.renderer = serviceResult.drawingInfo.renderer;
  306. layerData.legend = geoApi.symbology.rendererToLegend(layerData.renderer, featureIdx);
  307. geoApi.symbology.enhanceRenderer(layerData.renderer, layerData.legend);
  308. // temporarily store things for delayed attributes
  309. layerData.load = {
  310. // version number is only provided on 10.0 SP1 servers and up.
  311. // servers 10.1 and higher support the query limit flag
  312. supportsLimit: (serviceResult.currentVersion || 1) >= 10.1,
  313. layerUrl,
  314. attribs
  315. };
  316. }
  317. // return the layer data promise result
  318. resolve(layerData);
  319. } else {
  320. // case where error happened but service request was successful
  321. console.warn('Service metadata load error');
  322. if (serviceResult && serviceResult.error) {
  323. // reject with error
  324. reject(serviceResult.error);
  325. } else {
  326. reject(new Error('Unknown error loading service metadata'));
  327. }
  328. }
  329. }, error => {
  330. // failed to load service info. reject with error
  331. console.warn('Service metadata load error : ' + error);
  332. reject(error);
  333. });
  334. });
  335. return layerPackage;
  336. }
  337. // extract the options (including defaults) for a layer index
  338. function pluckOptions(featureIdx, options = {}) {
  339. // handle missing layer
  340. const opt = options[featureIdx] || {};
  341. return {
  342. skip: opt.skip || false,
  343. attribs: opt.attribs || '*'
  344. };
  345. }
  346. /**
  347. * Ochestrate the attribute extraction of a feature layer object.
  348. * @private
  349. * @param {Object} layer an ESRI API Feature layer object
  350. * @param {Object} options information on layer and attribute skipping
  351. * @param {Object} esriBundle bundle of API classes
  352. * @return {Object} attributes in layer bundle format (see newLayerBundle)
  353. */
  354. function processFeatureLayer(layer, options, esriBundle, geoApi) {
  355. // logic is in separate function to passify the cyclomatic complexity check.
  356. // TODO we may want to support the option of a layer that points to a server based JSON file containing attributes
  357. const result = newLayerBundle(layer.id);
  358. if (layer.url) {
  359. const idx = getLayerIndex(layer.url);
  360. const opts = pluckOptions(idx, options);
  361. // check for skip flag
  362. if (!opts.skip) {
  363. // call loadFeatureAttribs with options if present
  364. result.registerData(loadFeatureAttribs(layer.url, idx, opts.attribs, esriBundle, geoApi));
  365. }
  366. } else {
  367. // feature layer was loaded from a file.
  368. // this approach is inefficient (duplicates attributes in layer and in attribute store),
  369. // but provides a consistent approach to attributes regardless of where the layer came from
  370. const layerPackage = newLayerPackage(0, esriBundle); // files have no index (no server), so we use value 0
  371. // it's local, no need to lazy-load
  372. layerPackage._attribData = Promise.resolve(createAttribSet(layer.objectIdField, layer.graphics.map(elem => {
  373. return { attributes: elem.attributes };
  374. })));
  375. const renderer = layer.renderer.toJson();
  376. const legend = geoApi.symbology.rendererToLegend(renderer, 0);
  377. geoApi.symbology.enhanceRenderer(renderer, legend);
  378. // TODO revisit the geometry type. ideally, fix our GeoJSON to Feature to populate the property
  379. layerPackage.layerData = Promise.resolve({
  380. oidField: layer.objectIdField,
  381. fields: layer.fields,
  382. geometryType: layer.geometryType || JSON.parse(layer._json).layerDefinition.drawingInfo.geometryType,
  383. minScale: layer.minScale,
  384. maxScale: layer.maxScale,
  385. renderer,
  386. legend
  387. });
  388. result.registerData(layerPackage);
  389. }
  390. return result;
  391. }
  392. /**
  393. * Ochestrate the attribute extraction of a dynamic map service layer object.
  394. * @private
  395. * @param {Object} layer an ESRI API Dynamic Map Service layer object
  396. * @param {Object} options information on layer and attribute skipping
  397. * @param {Object} esriBundle bundle of API classes
  398. * @return {Object} attributes in layer bundle format (see newLayerBundle)
  399. */
  400. function processDynamicLayer(layer, options, esriBundle, geoApi) {
  401. // logic is in separate function to passify the cyclomatic complexity check.
  402. // TODO we may want to support the option of a layer that points to a server based JSON file containing attributes
  403. let idx = 0;
  404. let opts;
  405. const result = newLayerBundle(layer.id);
  406. const lInfo = layer.layerInfos;
  407. // for each layer leaf. we use a custom loop as we need to skip sections
  408. while (idx < lInfo.length) {
  409. opts = pluckOptions(idx, options);
  410. // check if leaf node or group node
  411. if (lInfo[idx].subLayerIds) {
  412. // group node
  413. if (opts.skip) {
  414. // skip past all child indexes (thus avoiding processing all children).
  415. // group indexes have property .subLayerIds that lists indexes of all immediate child layers
  416. // child layers can be group layers as well.
  417. // example: to skip Group A (index 0), we crawl to Leaf X (index 4), then add 1 to get to sibling layer Leaf W (index 5)
  418. // [0] Group A
  419. // [1] Leaf Z
  420. // [2] Group B
  421. // [3] Leaf Y
  422. // [4] Leaf X
  423. // [5] Leaf W
  424. let lastIdx = idx;
  425. while (lInfo[lastIdx].subLayerIds) {
  426. // find last child index of this group. the last child may be a group itself so we keep processing the while loop
  427. lastIdx = lInfo[lastIdx].subLayerIds[
  428. lInfo[lastIdx].subLayerIds.length - 1];
  429. }
  430. // lastIdx has made it to the very last child in the original group node.
  431. // advance by 1 to get the next sibling index to the group
  432. idx = lastIdx + 1;
  433. } else {
  434. // advance to the first child layer
  435. idx += 1;
  436. }
  437. } else {
  438. // leaf node
  439. if (!opts.skip) {
  440. // load the features, store promise in array
  441. result.registerData(loadFeatureAttribs(layer.url + '/' + idx.toString(), idx,
  442. opts.attribs, esriBundle, geoApi));
  443. }
  444. // advance the loop
  445. idx += 1;
  446. }
  447. }
  448. return result;
  449. }
  450. function loadLayerAttribsBuilder(esriBundle, geoApi) {
  451. /**
  452. * Fetch attributes from a server-based Layer
  453. * @param {Object} layer an ESRI API layer object
  454. * @param {Object} options settings to determine if sub layers or certain attributes should be skipped.
  455. * @return {Object} attributes bundle for given layer
  456. */
  457. return (layer, options) => {
  458. /*
  459. format of the options object
  460. all parts are optional. default values are skip: false and attribs: "*"
  461. {
  462. "<layerindex a>": {
  463. "skip": true
  464. },
  465. "<layerindex b>": {
  466. "skip": false,
  467. "attribs": "field3,field8,field11"
  468. },
  469. "<layerindex d>": {
  470. }
  471. }
  472. */
  473. const shr = shared(esriBundle);
  474. const lType = shr.getLayerType(layer);
  475. switch (lType) {
  476. case 'FeatureLayer':
  477. return processFeatureLayer(layer, options, esriBundle, geoApi);
  478. case 'ArcGISDynamicMapServiceLayer':
  479. return processDynamicLayer(layer, options, esriBundle, geoApi);
  480. // case 'WmsLayer':
  481. // case 'ArcGISTiledMapServiceLayer':
  482. default:
  483. throw new Error('no support for loading attributes from layer type ' + lType);
  484. }
  485. };
  486. }
  487. // Attribute Loader related functions
  488. // TODO consider re-writing all the asynch stuff with the ECMA-7 style of asynch keywords
  489. module.exports = (esriBundle, geoApi) => {
  490. return {
  491. loadLayerAttribs: loadLayerAttribsBuilder(esriBundle, geoApi),
  492. getLayerIndex
  493. };
  494. };