terriajs
Version:
Geospatial data visualization platform.
272 lines (246 loc) • 8.38 kB
text/typescript
;
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)
);
}