UNPKG

terriajs

Version:

Geospatial data visualization platform.

359 lines (311 loc) 10.5 kB
import Point from "@mapbox/point-geometry"; import arcGisPbfDecode from "arcgis-pbf-parser"; import { Feature, FeatureCollection, Position } from "geojson"; import { Feature as ProtomapsFeature, TileSource, Zxy } from "protomaps-leaflet"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; import Request from "terriajs-cesium/Source/Core/Request"; import Resource from "terriajs-cesium/Source/Core/Resource"; import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme"; import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo"; import { isGeometryCollection, isLine, isMultiLineString, isMultiPolygon, isPoint, isPolygon } from "../../../Core/GeoJson"; import { PROTOMAPS_TILE_BUFFER } from "../../ImageryProvider/ProtomapsImageryProvider"; import { GEOJSON_SOURCE_LAYER_NAME, geomTypeMap } from "./ProtomapsGeojsonSource"; interface ArcGisPbfSourceOptions { url: string; token?: string; objectIdField: string; outFields: string[]; maxRecordCountFactor: number; featuresPerTileRequest: number; maxTiledFeatures: number; tilingScheme: WebMercatorTilingScheme; enablePickFeatures: boolean; supportsQuantization: boolean; } export class ProtomapsArcGisPbfSource implements TileSource { private readonly baseResource: Resource; private readonly tilingScheme: WebMercatorTilingScheme; readonly objectIdField: string; private readonly outFields: string[]; private readonly maxRecordCountFactor: number; private readonly featuresPerTileRequest: number; private readonly maxTiledFeatures: number; private readonly enablePickFeatures: boolean; private readonly supportsQuantization: boolean; constructor(options: ArcGisPbfSourceOptions) { this.baseResource = new Resource(options.url); this.baseResource.appendForwardSlash(); if (options.token) { this.baseResource.setQueryParameters({ token: options.token }); } this.tilingScheme = options.tilingScheme; this.objectIdField = options.objectIdField; this.outFields = options.outFields; this.maxRecordCountFactor = options.maxRecordCountFactor; this.featuresPerTileRequest = options.featuresPerTileRequest; this.maxTiledFeatures = options.maxTiledFeatures; this.enablePickFeatures = options.enablePickFeatures; this.supportsQuantization = options.supportsQuantization; } public async get( c: Zxy, tileSizePixels: number, // TODO add support for canceling requests (through cesium) _request?: Request ): Promise<Map<string, ProtomapsFeature[]>> { const rect = this.tilingScheme.tileXYToNativeRectangle(c.x, c.y, c.z); const tileExtent = { xmin: rect.west, ymin: rect.south, xmax: rect.east, ymax: rect.north }; const tileWidthNative = tileExtent.xmax - tileExtent.xmin; const nativePixelSize = tileWidthNative / tileSizePixels; const tileExtentWithBuffer = { xmin: rect.west - PROTOMAPS_TILE_BUFFER * nativePixelSize, ymin: rect.south - PROTOMAPS_TILE_BUFFER * nativePixelSize, xmax: rect.east + PROTOMAPS_TILE_BUFFER * nativePixelSize, ymax: rect.north + PROTOMAPS_TILE_BUFFER * nativePixelSize }; const arcGisFeatures: Feature[] = []; let offset = 0; let fetching = true; while (fetching) { const tileResource = this.baseResource.getDerivedResource({ // Not sure how to handle request here - as we are making multiple requests // request: request }); tileResource.setQueryParameters({ f: "pbf", resultType: "tile", inSR: "102100", geometry: JSON.stringify(tileExtentWithBuffer), geometryType: "esriGeometryEnvelope", outFields: Array.from( new Set([this.objectIdField, ...this.outFields]) ).join(","), where: "1=1", maxRecordCountFactor: this.maxRecordCountFactor, resultRecordCount: this.featuresPerTileRequest, outSR: "102100", // Fetch in Web Mercator spatialRel: "esriSpatialRelIntersects", maxAllowableOffset: nativePixelSize, outSpatialReference: "102100", precision: "8", resultOffset: offset }); if (this.supportsQuantization) { tileResource.setQueryParameters({ quantizationParameters: JSON.stringify({ extent: tileExtentWithBuffer, spatialReference: { wkid: 102100, latestWkid: 3857 }, mode: "view", originPosition: "upperLeft", tolerance: nativePixelSize }) }); } const arrayBuffer = await tileResource.fetchArrayBuffer(); if (!arrayBuffer) { console.error("No data for URL: " + tileResource.url); fetching = false; continue; } const arcGisResponse = arcGisPbfDecode(new Uint8Array(arrayBuffer)); arcGisFeatures.push(...arcGisResponse.featureCollection.features); if (arcGisResponse.featureCollection.features.length === 0) { fetching = false; continue; } // If we have reached the max number of features per tile request, we need to fetch more if ( arcGisResponse.featureCollection.features.length >= this.featuresPerTileRequest ) { offset = offset + this.featuresPerTileRequest; } else { fetching = false; } if (offset >= this.maxTiledFeatures) { console.warn(`ArcGisPbfSource: maxTiledFeatures exceeded`); fetching = false; } } const protomapsFeatures: ProtomapsFeature[] = []; for (const f of arcGisFeatures) { processFeature(f, tileExtentWithBuffer, tileSizePixels).forEach((pf) => protomapsFeatures.push(pf) ); } const result = new Map<string, ProtomapsFeature[]>(); result.set(GEOJSON_SOURCE_LAYER_NAME, protomapsFeatures); return result; } public async pickFeatures( _x: number, _y: number, level: number, longitude: number, latitude: number ): Promise<ImageryLayerFeatureInfo[]> { if (!this.enablePickFeatures) { return []; } const features: ImageryLayerFeatureInfo[] = []; const pickFeatureResource = this.baseResource.getDerivedResource({ url: "query" }); // Get rough meters per pixel (at equator) for given zoom level const zoomMeters = 156543 / Math.pow(2, level); pickFeatureResource.setQueryParameters({ f: "geojson", sr: "4326", // Fetch in WGS84 geometryType: "esriGeometryPoint", geometry: JSON.stringify({ x: CesiumMath.toDegrees(longitude), y: CesiumMath.toDegrees(latitude), spatialReference: { wkid: 4326 } }), outFields: "*", // We don't need geometry in response, as we will create a "highlight imagery provider" for the feature, that uses the same ArcGisPbfSource returnGeometry: false, outSR: "4326", spatialRel: "esriSpatialRelIntersects", units: "esriSRUnit_Meter", distance: zoomMeters * 4 // 4 pixels wide (in meters) }); const json = (await pickFeatureResource.fetchJson()) as FeatureCollection; if (json.features) { for (const f of json.features) { const featureInfo = new ImageryLayerFeatureInfo(); featureInfo.data = f; featureInfo.properties = f.properties; featureInfo.configureDescriptionFromProperties(f.properties); featureInfo.configureNameFromProperties(f.properties); features.push(featureInfo); } } return features; } } /* Process GeoJSON (in Web Mercator) features into Protomaps features **/ function processFeature( feature: Feature, tileExtentWithBuffer: { xmin: number; ymin: number; xmax: number; ymax: number; }, tileWidthPixels: number ): ProtomapsFeature[] { const tileWidthWithBuffer = tileExtentWithBuffer.xmax - tileExtentWithBuffer.xmin; const tileWidthWithBufferPixels = tileWidthPixels + 2 * PROTOMAPS_TILE_BUFFER; const geomType = geomTypeMap(feature.geometry?.type); if (geomType === null) { return []; } const transformedGeom: Point[][] = []; let numVertices = 0; // Calculate bbox const bbox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; let points: Position[][]; if (isPoint(feature)) { points = [[feature.geometry.coordinates]]; } else if (isMultiLineString(feature)) { points = feature.geometry.coordinates; } else if (isPolygon(feature)) { points = feature.geometry.coordinates; } else if ( isMultiPolygon(feature) && feature.geometry.coordinates.length > 0 ) { return feature.geometry.coordinates.flatMap((polygon) => processFeature( { type: "Feature", properties: feature.properties, geometry: { type: "Polygon", coordinates: polygon } }, tileExtentWithBuffer, tileWidthPixels ) ); } else if (isLine(feature)) { points = [feature.geometry.coordinates]; } else if (isGeometryCollection(feature)) { return feature.geometry.geometries.flatMap((geometry) => processFeature( { type: "Feature", properties: feature.properties, geometry }, tileExtentWithBuffer, tileWidthPixels ) ); } else { return []; } for (const g1 of points) { const transformedG1: Point[] = []; for (const g2 of g1) { const transformedG2 = [ ((g2[0] - tileExtentWithBuffer.xmin) / tileWidthWithBuffer) * tileWidthWithBufferPixels - PROTOMAPS_TILE_BUFFER, (1 - (g2[1] - tileExtentWithBuffer.ymin) / tileWidthWithBuffer) * tileWidthWithBufferPixels - PROTOMAPS_TILE_BUFFER ]; if (bbox.minX > transformedG2[0]) { bbox.minX = transformedG2[0]; } if (bbox.maxX < transformedG2[0]) { bbox.maxX = transformedG2[0]; } if (bbox.minY > transformedG2[1]) { bbox.minY = transformedG2[1]; } if (bbox.maxY < transformedG2[1]) { bbox.maxY = transformedG2[1]; } transformedG1.push(new Point(transformedG2[0], transformedG2[1])); numVertices++; } transformedGeom.push(transformedG1); } return [ { props: feature.properties ?? {}, bbox, geomType, geom: transformedGeom, numVertices } ]; }