UNPKG

terriajs

Version:

Geospatial data visualization platform.

272 lines (246 loc) 8.38 kB
"use strict"; import Point from "@mapbox/point-geometry"; import { Feature as GeoJsonFeature, Geometry as GeoJsonGeometry, Position } from "@turf/helpers"; import { esriGeometryType, Feature as ArcGisFeature, FeatureSet, Point as ArcGisPoint, Polygon as ArcGisPolygon, Polyline as ArcGisPolyline, Position as ArcGisPosition, SpatialReference } from "arcgis-rest-api"; import defined from "terriajs-cesium/Source/Core/defined"; import WindingOrder from "terriajs-cesium/Source/Core/WindingOrder"; import filterOutUndefined from "../../Core/filterOutUndefined"; import JsonValue, { isJsonObject, isJsonValue } from "../../Core/Json"; import { FeatureCollectionWithCrs, GeoJsonCrs, toFeatureCollection } from "../../ModelMixins/GeojsonMixin"; import computeRingWindingOrder from "../Vector/computeRingWindingOrder"; const pointInPolygon = require("point-in-polygon"); /** * Converts feature data, such as from a WMS GetFeatureInfo or an Esri Identify, to * GeoJSON. The set of feature data formats supported by this function can be extended * by adding to {@link featureDataToGeoJson#supportedFormats}. * * @param {JsonValue} featureData The feature data to convert to GeoJSON. * @return {FeatureCollectionWithCrs | undefined} The GeoJSON representation of this feature data, or undefined if it cannot be converted to GeoJSON. */ export default function featureDataToGeoJson( featureData: unknown ): FeatureCollectionWithCrs | undefined { if (!isJsonValue(featureData)) { return undefined; } for (let i = 0; i < featureDataToGeoJson.supportedFormats.length; ++i) { const converted = featureDataToGeoJson.supportedFormats[i].converter(featureData); if (defined(converted)) { return converted; } } return undefined; } featureDataToGeoJson.supportedFormats = [ { name: "GeoJSON", converter: convertGeoJson }, { name: "Esri", converter: convertEsri } ]; function convertGeoJson(featureData: JsonValue) { return toFeatureCollection(featureData); } function convertEsri(featureData: JsonValue) { if ( (isEsriFeatureSet(featureData) || isEsriFeature(featureData)) && hasEsriGeometryType(featureData) ) return getEsriGeometry(featureData, featureData.geometryType); } function getEsriGeometry( featureData: FeatureSet | ArcGisFeature, geometryType: esriGeometryType ): FeatureCollectionWithCrs | undefined { const crs = esriSpatialReferenceToCrs( "geometry" in featureData ? featureData.geometry.spatialReference : featureData.spatialReference ); return { type: "FeatureCollection", crs: crs ? crs : undefined, features: filterOutUndefined( "geometry" in featureData ? [getEsriFeature(featureData, geometryType)] : featureData.features.map((f) => getEsriFeature(f, geometryType)) ) }; } function getEsriFeature( featureData: ArcGisFeature, geometryType: esriGeometryType ): GeoJsonFeature | undefined { let geojsonGeom: GeoJsonGeometry | undefined; if (!featureData?.geometry) return undefined; if (geometryType === "esriGeometryPolygon") { const geometry = featureData.geometry as ArcGisPolygon; // There are a bunch of differences between Esri polygons and GeoJSON polygons. // For GeoJSON, see https://tools.ietf.org/html/rfc7946#section-3.1.6. // For Esri, see http://resources.arcgis.com/en/help/arcgis-rest-api/#/Geometry_objects/02r3000000n1000000/ // In particular: // 1. Esri polygons can actually be multiple polygons by using multiple outer rings. GeoJSON polygons // can only have one outer ring and we need to use a MultiPolygon to represent multiple outer rings. // 2. In Esri which rings are outer rings and which are holes is determined by the winding order of the // rings. In GeoJSON, the first ring is the outer ring and subsequent rings are holes. // 3. In Esri polygons, clockwise rings are exterior, counter-clockwise are interior. In GeoJSON, the first // (exterior) ring is expected to be counter-clockwise, though lots of implementations probably don't // enforce this. The spec says, "For backwards compatibility, parsers SHOULD NOT reject // Polygons that do not follow the right-hand rule." // Group rings into outer rings and holes/ const outerRings: ArcGisPosition[][] = []; const holes: ArcGisPosition[][] = []; geometry.rings.forEach(function (ring) { if ( computeRingWindingOrder( filterOutUndefined(ring.map((p) => new Point(p[0], p[1]))) ) === WindingOrder.CLOCKWISE ) { outerRings.push(ring); } else { holes.push(ring); } // Reverse the coordinate order along the way due to #3 above. ring.reverse(); }); if (outerRings.length === 0 && holes.length > 0) { // Well, this is pretty weird. We have holes but not outer ring? // Most likely scenario is that someone messed up the winding order. // So let's treat all the holes as outer rings instead. holes.forEach((hole) => { Array.isArray(hole) ? hole.reverse() : null; }); outerRings.push(...holes); holes.length = 0; } // If there's only one outer ring, we can use a `Polygon` and things are simple. if (outerRings.length === 1) { geojsonGeom = { type: "Polygon", coordinates: [outerRings[0], ...holes] }; } else { // Multiple (or zero!) outer rings, so we need to use a multipolygon, and we need // to figure out which outer ring contains each hole. geojsonGeom = { type: "MultiPolygon", coordinates: outerRings.map((ring) => [ ring, ...findHolesInRing(ring, holes) ]) }; } } else if (geometryType === "esriGeometryPoint") { const geometry = featureData.geometry as ArcGisPoint; geojsonGeom = { type: "Point", coordinates: [geometry.x, geometry.y] }; } else if (geometryType === "esriGeometryPolyline") { const geometry = featureData.geometry as ArcGisPolyline; geojsonGeom = { type: "MultiLineString", coordinates: geometry.paths }; } else { return undefined; } if (geojsonGeom) { return { type: "Feature" as "Feature", properties: isJsonObject(featureData.attributes) ? featureData.attributes : {}, geometry: geojsonGeom }; } } function findHolesInRing(ring: Position[], holes: Position[][]) { // Return all holes where every vertex in the hole ring is inside the outer ring. return holes.filter((hole) => hole.every((coordinates) => pointInPolygon(coordinates, ring)) ); } function esriSpatialReferenceToCrs( spatialReference: SpatialReference | undefined ): GeoJsonCrs | undefined { let code: number | string | undefined; if (spatialReference) { if ("wkt" in spatialReference && spatialReference.wkt) { code = spatialReference.wkt; } if ("latestWkt" in spatialReference && spatialReference.latestWkt) { code = spatialReference.latestWkt; } if ("wkid" in spatialReference && spatialReference.wkid) { code = spatialReference.wkid; } if ("latestWkid" in spatialReference && spatialReference.latestWkid) { code = spatialReference.latestWkid; } } if (code === 102100) { return { type: "EPSG", properties: { code: "3857" } }; } else if (typeof code === "number") { return { type: "EPSG", properties: { code: code.toString() } }; } else if (typeof code === "string") { return { type: "name", properties: { name: code } }; } } /** Very very basic type test for EsriFeatureSet */ function isEsriFeatureSet(obj: any): obj is FeatureSet { return isJsonObject(obj, false) && Array.isArray(obj.features); } function isEsriFeature(obj: any): obj is ArcGisFeature { return isJsonObject(obj, false) && isJsonObject(obj.geometry, false); } function hasEsriGeometryType( obj: any ): obj is { geometryType: esriGeometryType } { return ( "geometryType" in obj && [ "esriGeometryPoint", "esriGeometryMultipoint", "esriGeometryPolyline", "esriGeometryPolygon", "esriGeometryEnvelope" ].includes(obj.geometryType) ); }