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