terriajs
Version:
Geospatial data visualization platform.
359 lines (311 loc) • 10.5 kB
text/typescript
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
}
];
}