layer.js

  1. 'use strict';
  2. // TODO consider splitting this module into different modules. in particular,
  3. // file-based stuff vs server based stuff, and layer creation vs layer support
  4. // TODO convert internal function header comments to JSDoc, and use the @private tag
  5. const csv2geojson = require('csv2geojson');
  6. const Terraformer = require('terraformer');
  7. const shp = require('shpjs');
  8. const ogc = require('./layer/ogc.js');
  9. const bbox = require('./layer/bbox.js');
  10. const layerRecord = require('./layer/layerRec/main.js');
  11. const defaultRenderers = require('./defaultRenderers.json');
  12. Terraformer.ArcGIS = require('terraformer-arcgis-parser');
  13. /**
  14. * Maps GeoJSON geometry types to a set of default renders defined in GlobalStorage.DefaultRenders
  15. * @property featureTypeToRenderer {Object}
  16. * @private
  17. */
  18. const featureTypeToRenderer = {
  19. Point: 'circlePoint',
  20. MultiPoint: 'circlePoint',
  21. LineString: 'solidLine',
  22. MultiLineString: 'solidLine',
  23. Polygon: 'outlinedPoly',
  24. MultiPolygon: 'outlinedPoly'
  25. };
  26. /**
  27. * Different types of services that a URL could point to
  28. * @property serviceType {Object}
  29. */
  30. const serviceType = {
  31. CSV: 'csv',
  32. GeoJSON: 'geojson',
  33. Shapefile: 'shapefile',
  34. FeatureLayer: 'featurelayer',
  35. RasterLayer: 'rasterlayer',
  36. GroupLayer: 'grouplayer',
  37. TileService: 'tileservice',
  38. FeatureService: 'featureservice',
  39. DynamicService: 'dynamicservice',
  40. ImageService: 'imageservice',
  41. WMS: 'wms',
  42. Unknown: 'unknown',
  43. Error: 'error'
  44. };
  45. // attempts to determine if a path points to a location on the internet,
  46. // or is a local file. Returns true if internetish
  47. function isServerFile(url) {
  48. // TODO possibly enhance to be better check, or support more cases
  49. const lowUrl = url.toLowerCase();
  50. const tests = [/^http:/, /^https:/, /^ftp:/, /^\/\//];
  51. return tests.some(test => lowUrl.match(test));
  52. }
  53. // will grab a file from a server address as binary.
  54. // returns a promise that resolves with the file data.
  55. function getServerFile(url, esriBundle) {
  56. return new Promise((resolve, reject) => {
  57. // extract info for this service
  58. const defService = esriBundle.esriRequest({
  59. url: url,
  60. handleAs: 'arraybuffer'
  61. });
  62. defService.then(srvResult => {
  63. resolve(srvResult);
  64. }, error => {
  65. // something went wrong
  66. reject(error);
  67. });
  68. });
  69. }
  70. // returns a standard information object with serviceType
  71. // supports predictLayerUrl
  72. // type is serviceType enum value
  73. function makeInfo(type) {
  74. return {
  75. serviceType: type,
  76. index: -1,
  77. tileSupport: false
  78. };
  79. }
  80. /**
  81. * Returns a standard information object with info common for most ESRI endpoints
  82. * .serviceName
  83. * .serviceType
  84. * .tileSupport
  85. * .rootUrl
  86. *
  87. * @function makeLayerInfo
  88. * @private
  89. * @param {String} type serviceType enum value for layer
  90. * @param {String} name property in json parameter containing a service name
  91. * @param {String} url url we are investigating
  92. * @param {Object} json data result from service we interrogated
  93. * @returns {Object}
  94. */
  95. function makeLayerInfo(type, name, url, json) {
  96. const info = makeInfo(type);
  97. info.serviceName = json[name] || '';
  98. info.rootUrl = url;
  99. if (type === serviceType.TileService) {
  100. info.tileSupport = true;
  101. info.serviceType = serviceType.DynamicService;
  102. }
  103. return info;
  104. }
  105. // returns promise of standard information object with serviceType
  106. // and fileData if file is located online (not on disk).
  107. function makeFileInfo(type, url, esriBundle) {
  108. return new Promise(resolve => {
  109. const info = makeInfo(type);
  110. if (url && isServerFile(url)) {
  111. // be a pal and download the file content
  112. getServerFile(url, esriBundle).then(data => {
  113. info.fileData = data;
  114. resolve(info);
  115. }).catch(() => {
  116. info.serviceType = serviceType.Error;
  117. resolve(info);
  118. });
  119. } else {
  120. resolve(info);
  121. }
  122. });
  123. }
  124. // inspects the JSON that was returned from a service.
  125. // if the JSON belongs to an ESRI endpoint, we do some terrible dog-logic to attempt
  126. // to derive what type of endpoint it is (mainly by looking for properties that are
  127. // unique to that type of service).
  128. // returns an enumeration value (string) from serviceType based on the match found.
  129. // non-esri services or unexpected esri services will return the .Unknown value
  130. function crawlEsriService(srvJson) {
  131. if (srvJson.type) {
  132. // a layer endpoint (i.e. url ends with integer index)
  133. const mapper = {
  134. 'Feature Layer': serviceType.FeatureLayer,
  135. 'Raster Layer': serviceType.RasterLayer,
  136. 'Group Layer': serviceType.GroupLayer
  137. };
  138. return mapper[srvJson.type] || serviceType.Unknown;
  139. } else if (srvJson.hasOwnProperty('singleFusedMapCache')) {
  140. if (srvJson.singleFusedMapCache) {
  141. // a tile server
  142. return serviceType.TileService;
  143. } else {
  144. // a map server
  145. return serviceType.DynamicService;
  146. }
  147. } else if (srvJson.hasOwnProperty('allowGeometryUpdates')) {
  148. // a feature server
  149. return serviceType.FeatureService;
  150. } else if (srvJson.hasOwnProperty('allowedMosaicMethods')) {
  151. // an image server
  152. return serviceType.ImageService;
  153. } else {
  154. return serviceType.Unknown;
  155. }
  156. }
  157. /**
  158. * handles the situation where our first poke revealed a child layer
  159. * (i.e. an indexed endpoint in an arcgis server). We need to extract
  160. * some extra information about the service it resides in (the root service)
  161. * and add it to our info package.
  162. *
  163. * @param {String} url the url of the original endpoint (including the index)
  164. * @param {Object} esriBundle has ESRI API objects
  165. * @param {Object} childInfo the information we have gathered on the child layer from the first poke
  166. * @returns {Promise} resolves with information object containing child and root information
  167. */
  168. function repokeEsriService(url, esriBundle, childInfo) {
  169. // break url into root and index
  170. const re = /\/(\d+)\/?$/;
  171. const matches = url.match(re);
  172. if (!matches) {
  173. // give up, dont crash with error.
  174. console.warn('Cannot extract layer index from url ' + url);
  175. return Promise.resolve(childInfo);
  176. }
  177. childInfo.index = parseInt(matches[1]);
  178. childInfo.rootUrl = url.substr(0, url.length - matches[0].length); // will drop trailing slash
  179. // inspect the server root
  180. return pokeEsriService(childInfo.rootUrl, esriBundle).then(rootInfo => {
  181. // take relevant info from root, mash it into our child package
  182. childInfo.tileSupport = rootInfo.tileSupport;
  183. childInfo.serviceType = rootInfo.serviceType;
  184. childInfo.layers = rootInfo.layers;
  185. return childInfo;
  186. });
  187. }
  188. // given a URL, attempt to read it as an ESRI rest endpoint.
  189. // returns a promise that resovles with an information object.
  190. // at minimum, the object will have a .serviceType property with a value from the above enumeration.
  191. // if the type is .Unknown, then we were unable to determine the url was an ESRI rest endpoint.
  192. // otherwise, we were successful, and the information object will have other properties depending on the service type
  193. // - .name : scraped from service, but may be rubbish (depends on service publisher). used as UI suggestion only
  194. // - .fields : for feature layer service only. list of fields to allow user to pick name field
  195. // - .geometryType : for feature layer service only. for help in defining the renderer, if required.
  196. // - .layers : for dynamic layer service only. lists the child layers
  197. function pokeEsriService(url, esriBundle, hint) {
  198. // reaction functions to different esri services
  199. const srvHandler = {};
  200. srvHandler[serviceType.FeatureLayer] = srvJson => {
  201. const info = makeLayerInfo(serviceType.FeatureLayer, 'name', url, srvJson);
  202. info.fields = srvJson.fields;
  203. info.geometryType = srvJson.geometryType;
  204. info.smartDefaults = {
  205. // TODO: try to find a name field if possible
  206. primary: info.fields[0].name // pick the first field as primary and return its name for ui binding
  207. };
  208. info.indexType = serviceType.FeatureLayer;
  209. return repokeEsriService(url, esriBundle, info);
  210. };
  211. srvHandler[serviceType.RasterLayer] = srvJson => {
  212. const info = makeLayerInfo(serviceType.RasterLayer, 'name', url, srvJson);
  213. info.indexType = serviceType.RasterLayer;
  214. return repokeEsriService(url, esriBundle, info);
  215. };
  216. srvHandler[serviceType.GroupLayer] = srvJson => {
  217. const info = makeLayerInfo(serviceType.GroupLayer, 'name', url, srvJson);
  218. info.indexType = serviceType.GroupLayer;
  219. return repokeEsriService(url, esriBundle, info);
  220. };
  221. srvHandler[serviceType.TileService] = srvJson => {
  222. const info = makeLayerInfo(serviceType.TileService, 'mapName', url, srvJson);
  223. info.layers = srvJson.layers;
  224. return info;
  225. };
  226. srvHandler[serviceType.DynamicService] = srvJson => {
  227. const info = makeLayerInfo(serviceType.DynamicService, 'mapName', url, srvJson);
  228. info.layers = srvJson.layers;
  229. return info;
  230. };
  231. srvHandler[serviceType.FeatureService] = srvJson => {
  232. const info = makeLayerInfo(serviceType.FeatureService, 'description', url, srvJson);
  233. info.layers = srvJson.layers;
  234. return info;
  235. };
  236. srvHandler[serviceType.ImageService] = srvJson => {
  237. const info = makeLayerInfo(serviceType.ImageService, 'name', url, srvJson);
  238. info.fields = srvJson.fields;
  239. return info;
  240. };
  241. // couldnt figure it out
  242. srvHandler[serviceType.Unknown] = () => {
  243. return makeInfo(serviceType.Unknown);
  244. };
  245. return new Promise(resolve => {
  246. // extract info for this service
  247. const defService = esriBundle.esriRequest({
  248. url: url,
  249. content: { f: 'json' },
  250. callbackParamName: 'callback',
  251. handleAs: 'json',
  252. });
  253. defService.then(srvResult => {
  254. // request didnt fail, indicating it is likely an ArcGIS Server endpoint
  255. let resultType = crawlEsriService(srvResult);
  256. if (hint && resultType !== hint) {
  257. // our hint doesn't match the service
  258. resultType = serviceType.Unknown;
  259. }
  260. resolve(srvHandler[resultType](srvResult));
  261. }, () => {
  262. // something went wrong, but that doesnt mean our service is invalid yet
  263. // it's likely not ESRI. return Error and let main predictor keep investigating
  264. resolve(makeInfo(serviceType.Error));
  265. });
  266. });
  267. }
  268. // tests a URL to see if the value is a file
  269. // providing the known type as a hint will cause the function to run the
  270. // specific logic for that file type, rather than guessing and trying everything
  271. // resolves with promise of information object
  272. // - serviceType : the type of file (CSV, Shape, GeoJSON, Unknown)
  273. // - fileData : if the file is located on a server, will xhr
  274. function pokeFile(url, esriBundle, hint) {
  275. // reaction functions to different files
  276. // overkill right now, as files just identify the type right now
  277. // but structure will let us enhance for custom things if we need to
  278. const fileHandler = {};
  279. // csv
  280. fileHandler[serviceType.CSV] = () => {
  281. return makeFileInfo(serviceType.CSV, url, esriBundle);
  282. };
  283. // geojson
  284. fileHandler[serviceType.GeoJSON] = () => {
  285. return makeFileInfo(serviceType.GeoJSON, url, esriBundle);
  286. };
  287. // csv
  288. fileHandler[serviceType.Shapefile] = () => {
  289. return makeFileInfo(serviceType.Shapefile, url, esriBundle);
  290. };
  291. // couldnt figure it out
  292. fileHandler[serviceType.Unknown] = () => {
  293. // dont supply url, as we don't want to download random files
  294. return makeFileInfo(serviceType.Unknown);
  295. };
  296. return new Promise(resolve => {
  297. if (hint) {
  298. // force the data extraction of the hinted format
  299. resolve(fileHandler[hint]());
  300. } else {
  301. // inspect the url for file extensions
  302. let guessType = serviceType.Unknown;
  303. switch (url.substr(url.lastIndexOf('.') + 1).toLowerCase()) {
  304. // check for file extensions
  305. case 'csv':
  306. guessType = serviceType.CSV;
  307. break;
  308. case 'zip':
  309. guessType = serviceType.Shapefile;
  310. break;
  311. case 'json':
  312. guessType = serviceType.GeoJSON;
  313. break;
  314. }
  315. resolve(fileHandler[guessType]());
  316. }
  317. });
  318. }
  319. // tests a URL to see if the value is a wms
  320. // resolves with promise of information object
  321. // - serviceType : the type of service (WMS, Unknown)
  322. function pokeWms(url, esriBundle) {
  323. // FIXME add some WMS detection logic. that would be nice
  324. console.log(url, esriBundle); // to stop jslint from quacking. remove when params are actually used
  325. return Promise.resolve(makeInfo(serviceType.WMS));
  326. }
  327. function predictLayerUrlBuilder(esriBundle) {
  328. /**
  329. * Attempts to determine what kind of layer the URL most likely is, and
  330. * if possible, return back some useful information about the layer
  331. *
  332. * - serviceType: the type of layer the function thinks the url is referring to. is a value of serviceType enumeration (string)
  333. * - fileData: file contents in an array buffer. only present if the URL points to a file that exists on an internet server (i.e. not a local disk drive)
  334. * - name: best attempt at guessing the name of the service (string). only present for ESRI service URLs
  335. * - fields: array of field definitions for the layer. conforms to ESRI's REST field standard. only present for feature layer and image service URLs.
  336. * - geometryType: describes the geometry of the layer (string). conforms to ESRI's REST geometry type enum values. only present for feature layer URLs.
  337. * - groupIdx: property only available if a group layer is queried. it is the layer index of the group layer in the list under its parent dynamic layer
  338. *
  339. * @method predictLayerUrl
  340. * @param {String} url a url to something that is hopefully a map service
  341. * @param {String} hint optional. allows the caller to specify the url type, forcing the function to run the data logic for that type
  342. * @returns {Promise} a promise resolving with an infomation object
  343. */
  344. return (url, hint) => {
  345. // TODO this function has lots of room to improve. there are many valid urls that it will
  346. // fail to identify correctly in it's current state
  347. // TODO refactor how this function works.
  348. // wait for the web service request library to not be esri/request
  349. // use new library to make a head call on the url provided
  350. // examine the content type of the head call result
  351. // if xml, assume WMS
  352. // if json, assume esri (may need extra logic to differentiate from json file?)
  353. // file case is explicit (e.g text/json)
  354. // then hit appropriate handler, do a second web request for content if required
  355. if (hint) {
  356. // go directly to appropriate logic block
  357. const hintToFlavour = {}; // why? cuz cyclomatic complexity + OBEY RULES
  358. const flavourToHandler = {};
  359. // hint type to hint flavour
  360. hintToFlavour[serviceType.CSV] = 'F_FILE';
  361. hintToFlavour[serviceType.GeoJSON] = 'F_FILE';
  362. hintToFlavour[serviceType.Shapefile] = 'F_FILE';
  363. hintToFlavour[serviceType.FeatureLayer] = 'F_ESRI';
  364. hintToFlavour[serviceType.RasterLayer] = 'F_ESRI';
  365. hintToFlavour[serviceType.GroupLayer] = 'F_ESRI';
  366. hintToFlavour[serviceType.TileService] = 'F_ESRI';
  367. hintToFlavour[serviceType.DynamicService] = 'F_ESRI';
  368. hintToFlavour[serviceType.ImageService] = 'F_ESRI';
  369. hintToFlavour[serviceType.WMS] = 'F_WMS';
  370. // hint flavour to flavour-handler
  371. flavourToHandler.F_FILE = () => {
  372. return pokeFile(url, esriBundle, hint);
  373. };
  374. flavourToHandler.F_ESRI = () => {
  375. return pokeEsriService(url, esriBundle, hint);
  376. };
  377. flavourToHandler.F_WMS = () => {
  378. // FIXME REAL LOGIC COMING SOON
  379. return pokeWms(url, esriBundle);
  380. };
  381. // execute handler. hint -> flavour -> handler -> run it -> promise
  382. return flavourToHandler[hintToFlavour[hint]]();
  383. } else {
  384. // TODO restructure. this approach cleans up the pyramid of doom.
  385. // Needs to add check for empty tests, resolve as unknown.
  386. // Still a potential to take advantage of the nice structure. Will depend
  387. // what comes first: WMS logic (adding a 3rd test), or changing the request
  388. // library, meaning we get the type early from the head request.
  389. /*
  390. tests = [pokeFile, pokeService];
  391. function runTests() {
  392. test = tests.pop();
  393. test(url, esriBundle).then(info => {
  394. if (info.serviceType !== serviceType.Unknown) {
  395. resolve(info);
  396. return;
  397. }
  398. runTests();
  399. });
  400. }
  401. runTests();
  402. */
  403. return new Promise(resolve => {
  404. // no hint. run tests until we find a match.
  405. // test for file
  406. pokeFile(url, esriBundle).then(infoFile => {
  407. if (infoFile.serviceType === serviceType.Unknown ||
  408. infoFile.serviceType === serviceType.Error) {
  409. // not a file, test for ESRI
  410. pokeEsriService(url, esriBundle).then(infoEsri => {
  411. if (infoEsri.serviceType === serviceType.Unknown ||
  412. infoEsri.serviceType === serviceType.Error) {
  413. // FIXME REAL LOGIC COMING SOON
  414. // pokeWMS
  415. resolve(infoEsri);
  416. } else {
  417. // it was a esri service. rejoice.
  418. // shortlived rejoice because grouped layers lul
  419. if (infoEsri.serviceType === serviceType.GroupLayer) {
  420. const lastSlash = url.lastIndexOf('/');
  421. const layerIdx = parseInt(url.substring(lastSlash + 1));
  422. url = url.substring(0, lastSlash);
  423. pokeEsriService(url, esriBundle).then(infoDynamic => {
  424. infoDynamic.groupIdx = layerIdx;
  425. resolve(infoDynamic);
  426. });
  427. } else {
  428. resolve(infoEsri);
  429. }
  430. }
  431. });
  432. } else {
  433. // it was a file. rejoice.
  434. resolve(infoFile);
  435. }
  436. });
  437. });
  438. }
  439. };
  440. }
  441. /**
  442. * Converts an array buffer to a string
  443. *
  444. * @method arrayBufferToString
  445. * @private
  446. * @param {Arraybuffer} buffer an array buffer containing stuff (ideally string-friendly)
  447. * @returns {String} array buffer in string form
  448. */
  449. function arrayBufferToString(buffer) {
  450. // handles UTF8 encoding
  451. return new TextDecoder('utf-8').decode(new Uint8Array(buffer));
  452. }
  453. /**
  454. * Performs validation on GeoJson object. Returns a promise resolving with the validation object.
  455. * Worker function for validateFile, see that file for return value specs
  456. *
  457. * @method validateGeoJson
  458. * @private
  459. * @param {Object} geoJson feature collection in geojson form
  460. * @returns {Promise} promise resolving with information on the geoJson object
  461. */
  462. function validateGeoJson(geoJson) {
  463. // GeoJSON geometry type to ESRI geometry type
  464. const geomMap = {
  465. Point: 'esriGeometryPoint',
  466. MultiPoint: 'esriGeometryMultipoint',
  467. LineString: 'esriGeometryPolyline',
  468. MultiLineString: 'esriGeometryPolyline',
  469. Polygon: 'esriGeometryPolygon',
  470. MultiPolygon: 'esriGeometryPolygon'
  471. };
  472. const fields = extractFields(geoJson);
  473. const res = {
  474. fields: fields,
  475. geometryType: geomMap[geoJson.features[0].geometry.type],
  476. formattedData: geoJson,
  477. smartDefaults: {
  478. // TODO: try to find a name field if possible
  479. primary: fields[0].name // pick the first field as primary and return its name for ui binding
  480. }
  481. };
  482. if (!res.geometryType) {
  483. return Promise.reject(new Error('Unexpected geometry type in GeoJSON'));
  484. }
  485. // TODO optional check: iterate through every feature, ensure geometry type and properties are all identical
  486. return Promise.resolve(res);
  487. }
  488. /**
  489. * Performs validation on csv data. Returns a promise resolving with the validation object.
  490. * Worker function for validateFile, see that file for return value specs
  491. *
  492. * @method validateCSV
  493. * @private
  494. * @param {Object} data csv data as string
  495. * @returns {Promise} promise resolving with information on the csv data
  496. */
  497. function validateCSV(data) {
  498. const formattedData = arrayBufferToString(data); // convert from arraybuffer to string to parsed csv. store string format for later
  499. const rows = csvPeek(formattedData, ','); // FIXME: this assumes delimiter is a `,`; need validation
  500. let errorMessage; // error message if any to return
  501. // validations
  502. if (rows.length === 0) {
  503. // fail, no rows
  504. errorMessage = 'File has no rows';
  505. } else {
  506. // field count of first row.
  507. const fc = rows[0].length;
  508. if (fc < 2) {
  509. // fail not enough columns
  510. errorMessage = 'File has less than two columns';
  511. } else {
  512. // check field counts of each row
  513. if (rows.every(rowArr => rowArr.length === fc)) {
  514. const res = {
  515. formattedData,
  516. smartDefaults: guessCSVfields(rows), // calculate smart defaults
  517. // make field list esri-ish for consistancy
  518. fields: rows[0].map(field => ({
  519. name: field,
  520. type: 'esriFieldTypeString'
  521. })),
  522. geometryType: 'esriGeometryPoint' // always point for CSV
  523. };
  524. return Promise.resolve(res);
  525. } else {
  526. errorMessage = 'File has inconsistent column counts';
  527. }
  528. }
  529. }
  530. return Promise.reject(new Error(errorMessage));
  531. }
  532. /**
  533. * Validates file content. Does some basic checking for errors. Attempts to get field list, and
  534. * if possible, provide the file in a more useful format. Promise rejection indicates failed validation
  535. *
  536. * - formattedData: file contents in a more useful format. JSON for GeoJSON and Shapefile. String for CSV
  537. * - fields: array of field definitions for the file. conforms to ESRI's REST field standard.
  538. * - geometryType: describes the geometry of the file (string). conforms to ESRI's REST geometry type enum values.
  539. *
  540. * @method validateFile
  541. * @param {String} type the format of file. aligns to serviceType enum (CSV, Shapefile, GeoJSON)
  542. * @param {Arraybuffer} data the file content in binary
  543. * @returns {Promise} a promise resolving with an infomation object
  544. */
  545. function validateFile(type, data) {
  546. const fileHandler = { // maps handlers for different file types
  547. [serviceType.CSV]: data => validateCSV(data),
  548. [serviceType.GeoJSON]: data => {
  549. const geoJson = JSON.parse(arrayBufferToString(data));
  550. return validateGeoJson(geoJson);
  551. },
  552. // convert from arraybuffer (containing zipped shapefile) to json (using shp library)
  553. [serviceType.Shapefile]: data =>
  554. shp(data).then(geoJson =>
  555. validateGeoJson(geoJson))
  556. };
  557. // trigger off the appropriate handler, return promise
  558. return fileHandler[type](data);
  559. }
  560. /**
  561. * From provided CSV data, guesses which columns are long and lat. If guessing is no successful, returns null for one or both fields.
  562. *
  563. * @method guessCSVfields
  564. * @private
  565. * @param {Array} rows csv data
  566. * @return {Object} an object with lat and long string properties indicating corresponding field names
  567. */
  568. function guessCSVfields(rows) {
  569. // magic regexes
  570. // TODO: in case of performance issues with running regexes on large csv files, limit to, say, the first hundred rows
  571. // TODO: explain regexes
  572. const latValueRegex = new RegExp(/^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?)$/); // filters by field value
  573. const longValueRegex = new RegExp(/^[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$/);
  574. const latNameRegex = new RegExp(/^.*(y|la).*$/i); // filters by field name
  575. const longNameRegex = new RegExp(/^.*(x|lo).*$/i);
  576. const latCandidates = findCandidates(rows, latValueRegex, latNameRegex); // filter out all columns that are not lat based on row values
  577. const longCandidates = findCandidates(rows, longValueRegex, longNameRegex); // filter out all columns that are not long based on row values
  578. // console.log(latCandidates);
  579. // console.log(longCandidates);
  580. // pick the first lat guess or null
  581. const lat = latCandidates[0] || null;
  582. // pick the first long guess or null
  583. const long = longCandidates.find(field => field !== lat) || null;
  584. // for primary field, pick the first on that is not lat or long field or null
  585. const primary = rows[0].find(field => field !== lat && field !== long) || null;
  586. return {
  587. lat,
  588. long,
  589. primary
  590. };
  591. function findCandidates(rows, valueRegex, nameRegex) {
  592. const fields = rows[0]; // first row must be headers
  593. const candidates =
  594. fields.filter((field, index) =>
  595. rows.every((row, rowIndex) =>
  596. rowIndex === 0 || valueRegex.test(row[index]))) // skip first row as its just headers
  597. .filter(field => nameRegex.test(field));
  598. return candidates;
  599. }
  600. }
  601. function serverLayerIdentifyBuilder(esriBundle) {
  602. // TODO we are using layerIds option property as it aligns with what the ESRI identify parameter
  603. // object uses. However, in r2 terminology, a layerId is specific to a full map layer, not
  604. // indexes of a single dynamic layer. for clarity, could consider renaming to .visibleLayers
  605. // and then map the value to the .layerIds property inside this function.
  606. /**
  607. * Perform a server-side identify on a layer (usually an ESRI dynamic layer)
  608. * Accepts the following options:
  609. * - geometry: Required. geometry in map co-ordinates for the area to identify.
  610. * will usually be an ESRI Point, though a polygon would work.
  611. * - mapExtent: Required. ESRI Extent of the current map view
  612. * - width: Required. Width of the map in pixels
  613. * - height: Required. Height of the map in pixels
  614. * - layerIds: an array of integers specifying the layer indexes to be examined. Will override the current
  615. * visible indexes in the layer parameter
  616. * - returnGeometry: a boolean indicating if result geometery should be returned with results. Defaults to false
  617. * - tolerance: an integer indicating how many screen pixels away from the mouse is valid for a hit. Defaults to 5
  618. *
  619. * @method serverLayerIdentify
  620. * @param {Object} layer an ESRI dynamic layer object
  621. * @param {Object} opts An object for supplying additional parameters
  622. * @returns {Promise} a promise resolving with an array of identify results (empty array if no hits)
  623. */
  624. return (layer, opts) => {
  625. const identParams = new esriBundle.IdentifyParameters();
  626. // pluck treats from options parameter
  627. if (opts) {
  628. const reqOpts = ['geometry', 'mapExtent', 'height', 'width'];
  629. reqOpts.forEach(optProp => {
  630. if (opts[optProp]) {
  631. identParams[optProp] = opts[optProp];
  632. } else {
  633. throw new Error(`serverLayerIdentify - missing opts.${ optProp } arguement`);
  634. }
  635. });
  636. identParams.layerIds = opts.layerIds || layer.visibleLayers;
  637. identParams.returnGeometry = opts.returnGeometry || false;
  638. identParams.layerOption = esriBundle.IdentifyParameters.LAYER_OPTION_ALL;
  639. identParams.spatialReference = opts.geometry.spatialReference;
  640. identParams.tolerance = opts.tolerance || 5;
  641. // TODO add support for identParams.layerDefinitions once attribute filtering is implemented
  642. } else {
  643. throw new Error('serverLayerIdentify - missing opts arguement');
  644. }
  645. // asynch an identify task
  646. return new Promise((resolve, reject) => {
  647. const identify = new esriBundle.IdentifyTask(layer.url);
  648. // TODO possibly tack on the layer.id to the resolved thing so we know which parent layer returned?
  649. // would only be required if the caller is mashing promises together and using Promise.all()
  650. identify.on('complete', result => {
  651. resolve(result.results);
  652. });
  653. identify.on('error', err => {
  654. reject(err.error);
  655. });
  656. identify.execute(identParams);
  657. });
  658. };
  659. }
  660. /**
  661. * Performs in place assignment of integer ids for a GeoJSON FeatureCollection.
  662. * If at least one feature has an existing id outside the geoJson properties section,
  663. * the original id value is copied in a newly created property ID_FILE of the properties object
  664. * and the existing id value is replaced by an autogenerated number.
  665. * Features without existing id from that same dataset will get a new properties ID_FILE
  666. * with an empty string as value.
  667. **************************************
  668. * If at least one feature has an existing OBJECTID inside the geoJson properties section,
  669. * the original OBJECTID value is copied in a newly created property OBJECTID_FILE of the properties object
  670. * and the existing OBJECTID value is replaced by an autogenerated number.
  671. * Features without existing OBJECTID from that same dataset will get a new properties OBJECTID_FILE
  672. * with an empty string as value.
  673. */
  674. function assignIds(geoJson) {
  675. if (geoJson.type !== 'FeatureCollection') {
  676. throw new Error('Assignment can only be performed on FeatureCollections');
  677. }
  678. let emptyID = true;
  679. let emptyObjID = true;
  680. // for every feature, if it does not have an id property, add it.
  681. // 0 is not a valid object id
  682. geoJson.features.forEach(function (val, idx) {
  683. Object.assign(val.properties, { ID_FILE: '', OBJECTID_FILE: '' });
  684. // to avoid double ID columns outside properties
  685. if ('id' in val && typeof val.id !== 'undefined') {
  686. val.properties.ID_FILE = val.id;
  687. emptyID = false;
  688. }
  689. // to avoid double OBJECTID columns. Useful for both geojson and CSV file.
  690. if ('OBJECTID' in val.properties) {
  691. val.properties.OBJECTID_FILE = val.properties.OBJECTID;
  692. delete val.properties.OBJECTID;
  693. emptyObjID = false;
  694. }
  695. val.id = idx + 1;
  696. });
  697. // remove ID_FILE if all empty
  698. if (emptyID) {
  699. geoJson.features.forEach(function (val) {
  700. delete val.properties.ID_FILE;
  701. });
  702. }
  703. // remove OBJECTID_FILE if all empty
  704. if (emptyObjID) {
  705. geoJson.features.forEach(function (val) {
  706. delete val.properties.OBJECTID_FILE;
  707. });
  708. }
  709. }
  710. /**
  711. * Extracts fields from the first feature in the feature collection, does no
  712. * guesswork on property types and calls everything a string.
  713. */
  714. function extractFields(geoJson) {
  715. if (geoJson.features.length < 1) {
  716. throw new Error('Field extraction requires at least one feature');
  717. }
  718. return Object.keys(geoJson.features[0].properties).map(function (prop) {
  719. return { name: prop, type: 'esriFieldTypeString' };
  720. });
  721. }
  722. /**
  723. * Makes an attempt to load and register a projection definition.
  724. * Returns promise resolving when process is complete
  725. * projModule - proj module from geoApi
  726. * projCode - the string or int epsg code we want to lookup
  727. * epsgLookup - function that will do the epsg lookup, taking code and returning promise of result or null
  728. */
  729. function projectionLookup(projModule, projCode, epsgLookup) {
  730. // look up projection definitions if it's not already loaded and we have enough info
  731. if (!projModule.getProjection(projCode) && epsgLookup && projCode) {
  732. return epsgLookup(projCode).then(projDef => {
  733. if (projDef) {
  734. // register projection
  735. projModule.addProjection(projCode, projDef);
  736. }
  737. return projDef;
  738. });
  739. } else {
  740. return Promise.resolve(null);
  741. }
  742. }
  743. function makeGeoJsonLayerBuilder(esriBundle, geoApi) {
  744. /**
  745. * Converts a GeoJSON object into a FeatureLayer. Expects GeoJSON to be formed as a FeatureCollection
  746. * containing a uniform feature type (FeatureLayer type will be set according to the type of the first
  747. * feature entry). Accepts the following options:
  748. * - targetWkid: Required. an integer for an ESRI wkid to project geometries to
  749. * - renderer: a string identifying one of the properties in defaultRenders
  750. * - sourceProjection: a string matching a proj4.defs projection to be used for the source data (overrides
  751. * geoJson.crs)
  752. * - fields: an array of fields to be appended to the FeatureLayer layerDefinition (OBJECTID is set by default)
  753. * - epsgLookup: a function that takes an EPSG code (string or number) and returns a promise of a proj4 style
  754. * definition or null if not found
  755. * - layerId: a string to use as the layerId
  756. * - colour: a hex string to define the symbol colour. e.g. '#33DD6A'
  757. *
  758. * @method makeGeoJsonLayer
  759. * @param {Object} geoJson An object following the GeoJSON specification, should be a FeatureCollection with
  760. * Features of only one type
  761. * @param {Object} opts An object for supplying additional parameters
  762. * @returns {Promise} a promise resolving with a {FeatureLayer}
  763. */
  764. return (geoJson, opts) => {
  765. // TODO add documentation on why we only support layers with WKID (and not WKT).
  766. let targetWkid;
  767. let srcProj = 'EPSG:4326'; // 4326 is the default for GeoJSON with no projection defined
  768. let layerId;
  769. const layerDefinition = {
  770. objectIdField: 'OBJECTID',
  771. fields: [
  772. {
  773. name: 'OBJECTID',
  774. type: 'esriFieldTypeOID',
  775. },
  776. ],
  777. };
  778. // ensure our features have ids
  779. assignIds(geoJson);
  780. layerDefinition.drawingInfo =
  781. defaultRenderers[featureTypeToRenderer[geoJson.features[0].geometry.type]];
  782. // attempt to get spatial reference from geoJson
  783. if (geoJson.crs && geoJson.crs.type === 'name') {
  784. srcProj = geoJson.crs.properties.name;
  785. }
  786. // pluck treats from options parameter
  787. if (opts) {
  788. if (opts.sourceProjection) {
  789. srcProj = opts.sourceProjection;
  790. }
  791. if (opts.targetWkid) {
  792. targetWkid = opts.targetWkid;
  793. } else {
  794. throw new Error('makeGeoJsonLayer - missing opts.targetWkid arguement');
  795. }
  796. if (opts.fields) {
  797. layerDefinition.fields = layerDefinition.fields.concat(opts.fields);
  798. }
  799. if (opts.layerId) {
  800. layerId = opts.layerId;
  801. } else {
  802. layerId = geoApi.shared.generateUUID();
  803. }
  804. // TODO add support for renderer option, or drop the option
  805. } else {
  806. throw new Error('makeGeoJsonLayer - missing opts arguement');
  807. }
  808. if (layerDefinition.fields.length === 1) {
  809. // caller has not supplied custom field list. so take them all.
  810. layerDefinition.fields = layerDefinition.fields.concat(extractFields(geoJson));
  811. }
  812. const destProj = 'EPSG:' + targetWkid;
  813. // look up projection definitions if they don't already exist and we have enough info
  814. const srcLookup = projectionLookup(geoApi.proj, srcProj, opts.epsgLookup);
  815. const destLookup = projectionLookup(geoApi.proj, destProj, opts.epsgLookup);
  816. // make the layer
  817. const buildLayer = new Promise(resolve => {
  818. // project data and convert to esri json format
  819. // console.log('reprojecting ' + srcProj + ' -> EPSG:' + targetWkid);
  820. geoApi.proj.projectGeojson(geoJson, destProj, srcProj);
  821. const esriJson = Terraformer.ArcGIS.convert(geoJson, { sr: targetWkid });
  822. const geometryType = layerDefinition.drawingInfo.geometryType;
  823. const fs = {
  824. features: esriJson,
  825. geometryType
  826. };
  827. const layer = new esriBundle.FeatureLayer(
  828. {
  829. layerDefinition: layerDefinition,
  830. featureSet: fs
  831. }, {
  832. mode: esriBundle.FeatureLayer.MODE_SNAPSHOT,
  833. id: layerId
  834. });
  835. // \(`O´)/ manually setting SR because it will come out as 4326
  836. layer.spatialReference = new esriBundle.SpatialReference({ wkid: targetWkid });
  837. if (opts.colour) {
  838. layer.renderer.symbol.color = new esriBundle.Color(opts.colour);
  839. }
  840. // initializing layer using JSON does not set this property. do it manually.
  841. layer.geometryType = geometryType;
  842. resolve(layer);
  843. });
  844. // call promises in order
  845. return srcLookup
  846. .then(() => destLookup)
  847. .then(() => buildLayer);
  848. };
  849. }
  850. function makeCsvLayerBuilder(esriBundle, geoApi) {
  851. /**
  852. * Constructs a FeatureLayer from CSV data. Accepts the following options:
  853. * - targetWkid: Required. an integer for an ESRI wkid the spatial reference the returned layer should be in
  854. * - renderer: a string identifying one of the properties in defaultRenders
  855. * - fields: an array of fields to be appended to the FeatureLayer layerDefinition (OBJECTID is set by default)
  856. * - latfield: a string identifying the field containing latitude values ('Lat' by default)
  857. * - lonfield: a string identifying the field containing longitude values ('Long' by default)
  858. * - delimiter: a string defining the delimiter character of the file (',' by default)
  859. * - epsgLookup: a function that takes an EPSG code (string or number) and returns a promise of a proj4 style
  860. * definition or null if not found
  861. * - layerId: a string to use as the layerId
  862. * - colour: a hex string to define the symbol colour. e.g. '#33DD6A'
  863. * @param {string} csvData the CSV data to be processed
  864. * @param {object} opts options to be set for the parser
  865. * @returns {Promise} a promise resolving with a {FeatureLayer}
  866. */
  867. return (csvData, opts) => {
  868. const csvOpts = { // default values
  869. latfield: 'Lat',
  870. lonfield: 'Long',
  871. delimiter: ','
  872. };
  873. // user options if
  874. if (opts) {
  875. if (opts.latfield) {
  876. csvOpts.latfield = opts.latfield;
  877. }
  878. if (opts.lonfield) {
  879. csvOpts.lonfield = opts.lonfield;
  880. }
  881. if (opts.delimiter) {
  882. csvOpts.delimiter = opts.delimiter;
  883. }
  884. }
  885. return new Promise((resolve, reject) => {
  886. csv2geojson.csv2geojson(csvData, csvOpts, (err, data) => {
  887. if (err) {
  888. console.warn('csv conversion error');
  889. console.log(err);
  890. reject(err);
  891. } else {
  892. // csv2geojson will not include the lat and long in the feature
  893. data.features.map(feature => {
  894. // add new property Long and Lat before layer is generated
  895. feature.properties[csvOpts.lonfield] = feature.geometry.coordinates[0];
  896. feature.properties[csvOpts.latfield] = feature.geometry.coordinates[1];
  897. });
  898. // TODO are we at risk adding params to the var that was passed in? should we make a copy and modify the copy?
  899. opts.sourceProjection = 'EPSG:4326'; // csv is always latlong
  900. opts.renderer = 'circlePoint'; // csv is always latlong
  901. // NOTE: since makeGeoJsonLayer is a "built" function, grab the built version from our link to api object
  902. geoApi.layer.makeGeoJsonLayer(data, opts).then(jsonLayer => {
  903. resolve(jsonLayer);
  904. });
  905. }
  906. });
  907. });
  908. };
  909. }
  910. /**
  911. * Peek at the CSV output (useful for checking headers)
  912. * @param {string} csvData the CSV data to be processed
  913. * @param {string} delimiter the delimiter used by the data
  914. * @returns {Array} an array of arrays containing the parsed CSV
  915. */
  916. function csvPeek(csvData, delimiter) {
  917. return csv2geojson.dsv(delimiter).parseRows(csvData);
  918. }
  919. function makeShapeLayerBuilder(esriBundle, geoApi) {
  920. /**
  921. * Constructs a FeatureLayer from Shapefile data. Accepts the following options:
  922. * - targetWkid: Required. an integer for an ESRI wkid the spatial reference the returned layer should be in
  923. * - renderer: a string identifying one of the properties in defaultRenders
  924. * - sourceProjection: a string matching a proj4.defs projection to be used for the source data (overrides
  925. * geoJson.crs)
  926. * - fields: an array of fields to be appended to the FeatureLayer layerDefinition (OBJECTID is set by default)
  927. * - epsgLookup: a function that takes an EPSG code (string or number) and returns a promise of a proj4 style
  928. * definition or null if not found
  929. * - layerId: a string to use as the layerId
  930. * - colour: a hex string to define the symbol colour. e.g. '#33DD6A'
  931. * @param {ArrayBuffer} shapeData an ArrayBuffer of the Shapefile in zip format
  932. * @param {object} opts options to be set for the parser
  933. * @returns {Promise} a promise resolving with a {FeatureLayer}
  934. */
  935. return (shapeData, opts) => {
  936. return new Promise((resolve, reject) => {
  937. // turn shape into geojson
  938. shp(shapeData).then(geoJson => {
  939. // turn geojson into feature layer
  940. // NOTE: since makeGeoJsonLayer is a "built" function, grab the built version from our link to api object
  941. geoApi.layer.makeGeoJsonLayer(geoJson, opts).then(jsonLayer => {
  942. resolve(jsonLayer);
  943. });
  944. }).catch(err => {
  945. reject(err);
  946. });
  947. });
  948. };
  949. }
  950. function getFeatureInfoBuilder(esriBundle) {
  951. /**
  952. * Fetches feature information, including geometry, from esri servers for feature layer.
  953. * @param {layerUrl} layerUrl linking to layer where feature layer resides
  954. * @param {objectId} objectId for feature to be retrived from a feature layer
  955. * @returns {Promise} promise resolves with an esri Graphic (http://resources.arcgis.com/en/help/arcgis-rest-api/#/Feature_Map_Service_Layer/02r3000000r9000000/)
  956. */
  957. return (layerUrl, objectId) => {
  958. return new Promise(
  959. (resolve, reject) => {
  960. const defData = esriBundle.esriRequest({
  961. url: layerUrl + objectId,
  962. content: {
  963. f: 'json',
  964. },
  965. callbackParamName: 'callback',
  966. handleAs: 'json'
  967. });
  968. defData.then(
  969. layerObj => {
  970. console.log(layerObj);
  971. resolve(layerObj);
  972. }, error => {
  973. console.warn(error);
  974. reject(error);
  975. }
  976. );
  977. });
  978. };
  979. }
  980. function createImageRecordBuilder(esriBundle, geoApi, classBundle) {
  981. /**
  982. * Creates an Image Layer Record class
  983. * @param {Object} config layer config values
  984. * @param {Object} esriLayer an optional pre-constructed layer
  985. * @param {Function} epsgLookup an optional lookup function for EPSG codes (see geoService for signature)
  986. * @returns {Object} instantited ImageRecord class
  987. */
  988. return (config, esriLayer, epsgLookup) => {
  989. return new classBundle.ImageRecord(esriBundle.ArcGISImageServiceLayer, geoApi, config, esriLayer, epsgLookup);
  990. };
  991. }
  992. function createFeatureRecordBuilder(esriBundle, geoApi, classBundle) {
  993. /**
  994. * Creates an Feature Layer Record class
  995. * @param {Object} config layer config values
  996. * @param {Object} esriLayer an optional pre-constructed layer
  997. * @param {Function} epsgLookup an optional lookup function for EPSG codes (see geoService for signature)
  998. * @returns {Object} instantited FeatureRecord class
  999. */
  1000. return (config, esriLayer, epsgLookup) => {
  1001. return new classBundle.FeatureRecord(esriBundle.FeatureLayer, esriBundle.esriRequest,
  1002. geoApi, config, esriLayer, epsgLookup);
  1003. };
  1004. }
  1005. function createDynamicRecordBuilder(esriBundle, geoApi, classBundle) {
  1006. /**
  1007. * Creates an Dynamic Layer Record class
  1008. * See DynamicRecord constructor for more detailed info on configIsComplete.
  1009. *
  1010. * @param {Object} config layer config values
  1011. * @param {Object} esriLayer an optional pre-constructed layer
  1012. * @param {Function} epsgLookup an optional lookup function for EPSG codes (see geoService for signature)
  1013. * @param {Boolean} configIsComplete an optional flag to indicate all child state values are provided in the config and should be used.
  1014. * @returns {Object} instantited DynamicRecord class
  1015. */
  1016. return (config, esriLayer, epsgLookup, configIsComplete = false) => {
  1017. return new classBundle.DynamicRecord(esriBundle.ArcGISDynamicMapServiceLayer, esriBundle.esriRequest,
  1018. geoApi, config, esriLayer, epsgLookup, configIsComplete);
  1019. };
  1020. }
  1021. function createTileRecordBuilder(esriBundle, geoApi, classBundle) {
  1022. /**
  1023. * Creates an Tile Layer Record class
  1024. * @param {Object} config layer config values
  1025. * @param {Object} esriLayer an optional pre-constructed layer
  1026. * @param {Function} epsgLookup an optional lookup function for EPSG codes (see geoService for signature)
  1027. * @returns {Object} instantited TileRecord class
  1028. */
  1029. return (config, esriLayer, epsgLookup) => {
  1030. return new classBundle.TileRecord(esriBundle.ArcGISTiledMapServiceLayer, geoApi, config,
  1031. esriLayer, epsgLookup);
  1032. };
  1033. }
  1034. function createWmsRecordBuilder(esriBundle, geoApi, classBundle) {
  1035. /**
  1036. * Creates an WMS Layer Record class
  1037. * @param {Object} config layer config values
  1038. * @param {Object} esriLayer an optional pre-constructed layer
  1039. * @param {Function} epsgLookup an optional lookup function for EPSG codes (see geoService for signature)
  1040. * @returns {Object} instantited WmsRecord class
  1041. */
  1042. return (config, esriLayer, epsgLookup) => {
  1043. return new classBundle.WmsRecord(esriBundle.WmsLayer, geoApi, config, esriLayer, epsgLookup);
  1044. };
  1045. }
  1046. /**
  1047. * Given 2D array in column x row format, check if all entries in the two given columns are numeric.
  1048. *
  1049. * @param {Array} arr is a 2D array based on the CSV file that contains row information for all of the rows
  1050. * @param {Integer} ind1 is a user specified index when uploading the CSV that specifies lat or long column (whichever isn't specified by ind2)
  1051. * @param {Integer} ind2 is a user specified index when uploading the CSV that specifies lat or long column (whichever isn't specified by ind1)
  1052. * @return {Boolean} returns true or false based on whether or not all all columns at ind1 and ind2 are numbers
  1053. */
  1054. function validateLatLong(arr, ind1, ind2) {
  1055. return arr.every(row => {
  1056. return !(isNaN(row[ind1]) || isNaN(row[ind2]));
  1057. });
  1058. }
  1059. // CAREFUL NOW!
  1060. // we are passing in a reference to geoApi. it is a pointer to the object that contains this module,
  1061. // along with other modules. it lets us access other modules without re-instantiating them in here.
  1062. module.exports = function (esriBundle, geoApi) {
  1063. const layerClassBundle = layerRecord(esriBundle, geoApi);
  1064. return {
  1065. ArcGISDynamicMapServiceLayer: esriBundle.ArcGISDynamicMapServiceLayer,
  1066. ArcGISImageServiceLayer: esriBundle.ArcGISImageServiceLayer,
  1067. GraphicsLayer: esriBundle.GraphicsLayer,
  1068. FeatureLayer: esriBundle.FeatureLayer,
  1069. ScreenPoint: esriBundle.ScreenPoint,
  1070. Query: esriBundle.Query,
  1071. TileLayer: esriBundle.ArcGISTiledMapServiceLayer,
  1072. ogc: ogc(esriBundle),
  1073. bbox: bbox(esriBundle, geoApi),
  1074. createImageRecord: createImageRecordBuilder(esriBundle, geoApi, layerClassBundle),
  1075. createWmsRecord: createWmsRecordBuilder(esriBundle, geoApi, layerClassBundle),
  1076. createTileRecord: createTileRecordBuilder(esriBundle, geoApi, layerClassBundle),
  1077. createDynamicRecord: createDynamicRecordBuilder(esriBundle, geoApi, layerClassBundle),
  1078. createFeatureRecord: createFeatureRecordBuilder(esriBundle, geoApi, layerClassBundle),
  1079. LayerDrawingOptions: esriBundle.LayerDrawingOptions,
  1080. getFeatureInfo: getFeatureInfoBuilder(esriBundle),
  1081. makeGeoJsonLayer: makeGeoJsonLayerBuilder(esriBundle, geoApi),
  1082. makeCsvLayer: makeCsvLayerBuilder(esriBundle, geoApi),
  1083. makeShapeLayer: makeShapeLayerBuilder(esriBundle, geoApi),
  1084. serverLayerIdentify: serverLayerIdentifyBuilder(esriBundle),
  1085. predictLayerUrl: predictLayerUrlBuilder(esriBundle),
  1086. validateFile,
  1087. csvPeek,
  1088. serviceType,
  1089. validateLatLong
  1090. };
  1091. };