proj.js

  1. 'use strict';
  2. const proj4 = require('proj4');
  3. const terraformer = require('terraformer');
  4. const teraProj = require('terraformer-proj4js');
  5. /**
  6. * Reproject a GeoJSON object in place. This is a wrapper around terraformer-proj4js.
  7. * @param {Object} geojson the GeoJSON to be reprojected, this will be modified in place
  8. * @param {String|Number} outputSpatialReference the target spatial reference,
  9. * 'EPSG:4326' is used by default; if a number is suppied it will be used as an EPSG code
  10. * @param {String|Number} inputSpatialReference same rules as outputSpatialReference if suppied
  11. * if missing it will attempt to find it encoded in the GeoJSON
  12. */
  13. function projectGeojson(geojson, outputSpatialReference, inputSpatialReference) {
  14. const converter = teraProj(terraformer, proj4);
  15. converter(geojson, outputSpatialReference, inputSpatialReference);
  16. }
  17. /**
  18. * Convert a projection to an string that is compatible with proj4. If it is an ESRI SpatialReference or an integer it will be converted.
  19. * @param {Object|Integer|String} proj an ESRI SpatialReference, integer or string. Strings will be unchanged and unchecked,
  20. * ints and SpatialReference objects will be converted.
  21. * @return {String} A string in the form EPSG:####
  22. * @private
  23. */
  24. function normalizeProj(proj) {
  25. if (typeof proj === 'object') {
  26. if (proj.wkid) {
  27. return 'EPSG:' + proj.wkid;
  28. } else if (proj.wkt) {
  29. return proj.wkt;
  30. }
  31. } else if (typeof proj === 'number') {
  32. return 'EPSG:' + proj;
  33. } else if (typeof proj === 'string') {
  34. return proj;
  35. }
  36. throw new Error('Bad argument type, please provide a string, integer or SpatialReference object.');
  37. }
  38. /**
  39. * Project a single point.
  40. * @param {Object|Integer|String} srcProj the spatial reference of the point (as ESRI SpatialReference, integer WKID or an EPSG string)
  41. * @param {Object|Integer|String} destProj the spatial reference of the result (as ESRI SpatialReference, integer WKID or an EPSG string)
  42. * @param {Array|Object} point a 2d array or object with {x,y} props containing the coordinates to Reproject
  43. * @return {Array|Object} a 2d array or object containing the projected point
  44. */
  45. function localProjectPoint(srcProj, destProj, point) {
  46. return proj4(normalizeProj(srcProj), normalizeProj(destProj), point);
  47. }
  48. /**
  49. * Project a single point.
  50. * @param {Object|Integer|String} destProj the spatial reference of the result (as ESRI SpatialReference, integer WKID or an EPSG string)
  51. * @param {Object} geometry an object conforming to ESRI Geometry object standards containing the coordinates to Reproject
  52. * @return {Object} an object conforming to ESRI Geomtery object standards containing the input geometry in the destination projection
  53. */
  54. function localProjectGeometry(destProj, geometry) {
  55. // FIXME we seem to be really dependant on wkid. ideally enhance to handle all SR types
  56. // HACK >:'(
  57. // terraformer has this undesired behavior where, if your input geometry is in WKID 102100, it will magically
  58. // project all your co-ordinates to lat/long when converting between ESRI and GeoJSON formats.
  59. // to stop it from ruining us, we temporarily set the spatial reference to nonsense so it will leave it alone
  60. const realSR = geometry.spatialReference;
  61. geometry.spatialReference = { wkid: 8888 }; // nonsense!
  62. const grGeoJ = terraformer.ArcGIS.parse(geometry, { sr: 8888 });
  63. geometry.spatialReference = realSR;
  64. // project json
  65. projectGeojson(grGeoJ, normalizeProj(destProj), normalizeProj(realSR));
  66. // back to esri format
  67. const grEsri = terraformer.ArcGIS.convert(grGeoJ);
  68. // doing this because .convert likes to attach a lat/long spatial reference for fun.
  69. grEsri.spatialReference = destProj;
  70. return grEsri;
  71. }
  72. /**
  73. * Reproject an EsriExtent object on the client. Does not require network
  74. * traffic, but may not handle conversion between projection types as well.
  75. * Internally it tests 8 points along each edge and takes the max extent
  76. * of the result.
  77. *
  78. * @param {EsriExtent} extent to reproject
  79. * @param {Object} sr is the target spatial reference (if a number it
  80. * will be treated as a WKID)
  81. * @returns {Object} an extent as an unstructured object
  82. */
  83. function localProjectExtent(extent, sr) {
  84. // interpolates two points by splitting the line in half recursively
  85. function interpolate(p0, p1, steps) {
  86. if (steps === 0) { return [p0, p1]; }
  87. let mid = [(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2];
  88. if (steps === 1) {
  89. return [p0, mid, p1];
  90. }
  91. if (steps > 1) {
  92. let i0 = interpolate(p0, mid, steps - 1);
  93. let i1 = interpolate(mid, p1, steps - 1);
  94. return i0.concat(i1.slice(1));
  95. }
  96. }
  97. const points = [[extent.xmin, extent.ymin], [extent.xmax, extent.ymin],
  98. [extent.xmax, extent.ymax], [extent.xmin, extent.ymax],
  99. [extent.xmin, extent.ymin]];
  100. let interpolatedPoly = [];
  101. let srcProj;
  102. // interpolate each edge by splitting it in half 3 times (since lines are not guaranteed to project to lines we need to consider
  103. // max / min points in the middle of line segments)
  104. [0, 1, 2, 3]
  105. .map(i => interpolate(points[i], points[i + 1], 3).slice(1))
  106. .forEach(seg => interpolatedPoly = interpolatedPoly.concat(seg));
  107. // find the source extent (either from wkid or wkt)
  108. if (extent.spatialReference.wkid) {
  109. srcProj = 'EPSG:' + extent.spatialReference.wkid;
  110. } else if (extent.spatialReference.wkt) {
  111. srcProj = extent.spatialReference.wkt;
  112. } else {
  113. throw new Error('No WKT or WKID specified on extent.spatialReference');
  114. }
  115. // find the destination extent
  116. let destProj = normalizeProj(sr);
  117. if (extent.spatialReference.wkid && !proj4.defs(srcProj)) {
  118. throw new Error('Source projection WKID not recognized by proj4 library');
  119. }
  120. const projConvert = proj4(srcProj, destProj);
  121. const transformed = interpolatedPoly.map(x => projConvert.forward(x));
  122. const xvals = transformed.map(x => x[0]);
  123. const yvals = transformed.map(x => x[1]);
  124. const x0 = Math.min.apply(null, xvals);
  125. const x1 = Math.max.apply(null, xvals);
  126. const y0 = Math.min.apply(null, yvals);
  127. const y1 = Math.max.apply(null, yvals);
  128. return { x0, y0, x1, y1, sr };
  129. }
  130. /**
  131. * Check whether or not a spatialReference is supported by proj4 library.
  132. *
  133. * @param {Object} spatialReference to be checked to see if it's supported by proj4
  134. * @param {Function} epsgLookup an optional lookup function for EPSG codes which are not loaded
  135. * in the proj4 definitions, the function should take a numeric EPSG code and return a Promise
  136. * resolving with a proj4 style definition string
  137. * @returns {Object} with the structure {
  138. * foundProj: (bool) indicates if the projection was found,
  139. * message: (string) provides a reason why the projection was not found,
  140. * lookupPromise: (Promise) an optional promise resolving with true or false if a lookup function was provided and had to be invoked
  141. * }
  142. */
  143. function checkProj(spatialReference, epsgLookup) {
  144. let srcProj;
  145. // find the source extent (either from wkid or wkt)
  146. if (spatialReference.wkid) {
  147. srcProj = 'EPSG:' + spatialReference.wkid;
  148. } else if (spatialReference.wkt) {
  149. srcProj = spatialReference.wkt;
  150. } else {
  151. return {
  152. foundProj: false,
  153. message: 'No WKT or WKID specified on extent.spatialReference'
  154. };
  155. }
  156. if (spatialReference.wkid && !proj4.defs(srcProj)) {
  157. if (epsgLookup) {
  158. return {
  159. foundProj: false,
  160. message: 'Attempting to lookup WKID',
  161. lookupPromise: epsgLookup(spatialReference.wkid).then(def => {
  162. if (def === null) {
  163. return false;
  164. }
  165. proj4.defs(srcProj, def);
  166. return true;
  167. })
  168. };
  169. }
  170. return {
  171. foundProj: false,
  172. message: 'Source projection in WKID and not recognized by proj4 library'
  173. };
  174. }
  175. return {
  176. foundProj: true,
  177. message: 'Source projection OK.'
  178. };
  179. }
  180. function projectEsriExtentBuilder(esriBundle) {
  181. return (extent, sr) => {
  182. const p = localProjectExtent(extent, sr);
  183. return new esriBundle.Extent(p.x0, p.y0, p.x1, p.y1, p.sr);
  184. };
  185. }
  186. function esriServiceBuilder(esriBundle) {
  187. /**
  188. * Reproject an esri geometry object on the server. Requires network traffic
  189. * to esri's Geometry Service, but may be slower than proj4 conversion.
  190. * Internally it tests 1 point and reprojects it to another spatial reference.
  191. *
  192. * @param {url} url for the ESRI Geometry Service
  193. * @param {geometries} geometries to be projected
  194. * @param {sr} sr is the target spatial reference
  195. * @returns {Promise} promise to return reprojected geometries
  196. */
  197. return (url, geometries, sr) => {
  198. return new Promise(
  199. (resolve, reject) => {
  200. const params = new esriBundle.ProjectParameters();
  201. // connect to esri server
  202. const gsvc = new esriBundle.GeometryService(url);
  203. params.geometries = geometries;
  204. params.outSR = sr;
  205. // call project function from esri server to do conversion
  206. gsvc.project(params,
  207. projectedExtents => {
  208. resolve(projectedExtents);
  209. }, error => {
  210. reject(error);
  211. });
  212. });
  213. };
  214. }
  215. /**
  216. * Checks if two spatial reference objects are equivalent. Handles both wkid and wkt definitions.
  217. *
  218. * @method isSpatialRefEqual
  219. * @static
  220. * @param {type} sr1 Esri Spatial Reference First to compare
  221. * @param {type} sr2 Esri Spatial Reference Second to compare
  222. * @return {Boolean} true if the two spatial references are equivalent. False otherwise.
  223. */
  224. function isSpatialRefEqual(sr1, sr2) {
  225. if ((sr1.wkid) && (sr2.wkid)) {
  226. // both SRs have wkids
  227. return sr1.wkid === sr2.wkid;
  228. } else if ((sr1.wkt) && (sr2.wkt)) {
  229. // both SRs have wkt's
  230. return sr1.wkt === sr2.wkt;
  231. } else {
  232. // not enough info provided or mismatch between wkid and wkt.
  233. return false;
  234. }
  235. }
  236. module.exports = function (esriBundle) {
  237. // TODO: Move Point and SpatialReference to its own (geometry) module
  238. // TODO consider moving this elsewhere. state is bad, but these are common, and we have no service for esri defs
  239. proj4.defs('EPSG:3978', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=49 ' +
  240. '+lon_0=-95 +x_0=0 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');
  241. proj4.defs('EPSG:3979', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=49 +lon_0=-95 ' +
  242. '+x_0=0 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');
  243. proj4.defs('EPSG:54004', '+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 +ellps=WGS84 ' +
  244. '+datum=WGS84 +units=m +no_defs');
  245. proj4.defs('EPSG:102100', proj4.defs('EPSG:3857'));
  246. return {
  247. addProjection: proj4.defs, // straight passthrough at the moment, maybe add arg checking (two args)?
  248. checkProj,
  249. getProjection: proj4.defs, // straight passthrough at the moment, maybe add arg checking (one arg)?
  250. esriServerProject: esriServiceBuilder(esriBundle),
  251. Graphic: esriBundle.Graphic,
  252. graphicsUtils: esriBundle.graphicsUtils,
  253. isSpatialRefEqual,
  254. localProjectExtent,
  255. localProjectPoint,
  256. localProjectGeometry,
  257. projectGeojson,
  258. Point: esriBundle.Point,
  259. projectEsriExtent: projectEsriExtentBuilder(esriBundle),
  260. SpatialReference: esriBundle.SpatialReference
  261. };
  262. };