UNPKG

terriajs

Version:

Geospatial data visualization platform.

320 lines (276 loc) 8.91 kB
import Point from "@mapbox/point-geometry"; import bbox from "@turf/bbox"; import booleanIntersects from "@turf/boolean-intersects"; import circle from "@turf/circle"; import { featureCollection } from "@turf/helpers"; import { Feature } from "geojson"; import geojsonvt from "geojson-vt"; import { cloneDeep } from "lodash-es"; import { makeObservable, observable, runInAction } from "mobx"; import { Bbox, GeomType, Feature as ProtomapsFeature, TileSource, Zxy } from "protomaps-leaflet"; import Cartographic from "terriajs-cesium/Source/Core/Cartographic"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo"; import { FeatureCollectionWithCrs, toFeatureCollection } from "../../../Core/GeoJson"; import { LAYER_NAME_PROP, PROTOMAPS_DEFAULT_TILE_SIZE, PROTOMAPS_TILE_BUFFER } from "../../ImageryProvider/ProtomapsImageryProvider"; /** Extent (of coordinates) of tiles generated by geojson-vt */ export const GEOJSON_VT_EXTENT = 4096; /** Layer name to use with geojson-vt * This must be used in PaintRules/LabelRules (eg `dataLayer: "layer"`) */ export const GEOJSON_SOURCE_LAYER_NAME = "layer"; /** Protomaps Geojson source * This source uses geojson-vt to tile geojson data * It is designed to be used with ProtomapsImageryProvider */ export class ProtomapsGeojsonSource implements TileSource { /** Data object from Options */ private readonly data: string | FeatureCollectionWithCrs; /** Resolved geojsonObject (if applicable) */ @observable.ref geojsonObject: FeatureCollectionWithCrs | undefined; /** Geojson-vt tileIndex (if applicable) */ tileIndex: Promise<ReturnType<typeof geojsonvt>> | undefined; constructor(url: string | FeatureCollectionWithCrs) { makeObservable(this); this.data = url; if (typeof url !== "string") { this.geojsonObject = url; } } /** Fetch geoJSON data (if required) and tile with geojson-vt */ private async fetchData() { let result: FeatureCollectionWithCrs; if (typeof this.data === "string") { result = toFeatureCollection(await (await fetch(this.data)).json()) ?? featureCollection([]); } else { result = this.data; } runInAction(() => (this.geojsonObject = result)); return geojsonvt(result as geojsonvt.Data, { buffer: (PROTOMAPS_TILE_BUFFER / PROTOMAPS_DEFAULT_TILE_SIZE) * GEOJSON_VT_EXTENT, extent: GEOJSON_VT_EXTENT, maxZoom: 24 }); } public async get( c: Zxy, tileSize: number ): Promise<Map<string, ProtomapsFeature[]>> { if (!this.tileIndex) { this.tileIndex = this.fetchData(); } // request a particular tile const tile = (await this.tileIndex).getTile(c.z, c.x, c.y); const result = new Map<string, ProtomapsFeature[]>(); if (tile && tile.features && tile.features.length > 0) { result.set( GEOJSON_SOURCE_LAYER_NAME, geojsonVtTileToProtomapsFeatures(tile.features, tileSize) ); } return result; } public async pickFeatures( _x: number, _y: number, level: number, longitude: number, latitude: number ): Promise<ImageryLayerFeatureInfo[]> { if (!this.geojsonObject) return []; const featureInfos: ImageryLayerFeatureInfo[] = []; // Get rough meters per pixel (at equator) for given zoom level const zoomMeters = 156543 / Math.pow(2, level); // Create circle with 10 pixel radius to pick features const buffer = circle( [CesiumMath.toDegrees(longitude), CesiumMath.toDegrees(latitude)], 10 * zoomMeters, { steps: 10, units: "meters" } ); // Create wrappedBuffer with only positive coordinates - this is needed for features which overlap antemeridian const wrappedBuffer = cloneDeep(buffer); wrappedBuffer.geometry.coordinates.forEach((ring) => ring.forEach((point) => { point[0] = point[0] < 0 ? point[0] + 360 : point[0]; }) ); const bufferBbox = bbox(buffer); // Get array of all features const geojsonFeatures = this.geojsonObject.features; const pickedFeatures: Feature[] = []; for (let index = 0; index < geojsonFeatures.length; index++) { const feature = geojsonFeatures[index]; if (!feature.bbox) { feature.bbox = bbox(feature); } // Filter by bounding box and then intersection with buffer (to minimize calls to booleanIntersects) if ( Math.max( feature.bbox[0], // Wrap buffer bbox if necessary feature.bbox[0] > 180 ? bufferBbox[0] + 360 : bufferBbox[0] ) <= Math.min( feature.bbox[2], // Wrap buffer bbox if necessary feature.bbox[2] > 180 ? bufferBbox[2] + 360 : bufferBbox[2] ) && Math.max(feature.bbox[1], bufferBbox[1]) <= Math.min(feature.bbox[3], bufferBbox[3]) ) { // If we have longitudes greater than 180 - used wrappedBuffer if (feature.bbox[0] > 180 || feature.bbox[2] > 180) { if (booleanIntersects(feature, wrappedBuffer)) pickedFeatures.push(feature); } else if (booleanIntersects(feature, buffer)) pickedFeatures.push(feature); } } // Convert pickedFeatures to ImageryLayerFeatureInfos pickedFeatures.forEach((f) => { const featureInfo = new ImageryLayerFeatureInfo(); featureInfo.data = f; featureInfo.properties = Object.assign( { [LAYER_NAME_PROP]: GEOJSON_SOURCE_LAYER_NAME }, f.properties ?? {} ); if ( f.geometry.type === "Point" && typeof f.geometry.coordinates[0] === "number" && typeof f.geometry.coordinates[1] === "number" ) { featureInfo.position = Cartographic.fromDegrees( f.geometry.coordinates[0], f.geometry.coordinates[1] ); } featureInfo.configureDescriptionFromProperties(f.properties); featureInfo.configureNameFromProperties(f.properties); featureInfos.push(featureInfo); }); return featureInfos; } } export const geomTypeMap = ( type: string | null | undefined ): GeomType | null => { switch (type) { case "Point": case "MultiPoint": return GeomType.Point; case "LineString": case "MultiLineString": return GeomType.Line; case "Polygon": case "MultiPolygon": return GeomType.Polygon; default: return null; } }; export function geojsonVtTileToProtomapsFeatures( features: geojsonvt.Feature[], tileSize: number ): ProtomapsFeature[] { const scale = tileSize / GEOJSON_VT_EXTENT; return features .map((f) => { let transformedGeom: Point[][]; let numVertices: number; // Calculate bbox const bbox: Bbox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; // Multi-polygon if (Array.isArray(f.geometry[0][0])) { // Note: the type is incorrect here const geom = f.geometry as unknown as [number, number][][]; transformedGeom = geom.map((g1) => g1.map((g2) => { const x = g2[0] * scale; const y = g2[1] * scale; if (bbox.minX > x) { bbox.minX = x; } if (bbox.maxX < x) { bbox.maxX = x; } if (bbox.minY > y) { bbox.minY = y; } if (bbox.maxY < y) { bbox.maxY = y; } return new Point(x, y); }) ); numVertices = transformedGeom.reduce<number>( (count, current) => count + current.length, 0 ); } // Other feature types else { const geom = f.geometry as [number, number][]; transformedGeom = [ geom.map((g1) => { const x = g1[0] * scale; const y = g1[1] * scale; if (bbox.minX > x) { bbox.minX = x; } if (bbox.maxX < x) { bbox.maxX = x; } if (bbox.minY > y) { bbox.minY = y; } if (bbox.maxY < y) { bbox.maxY = y; } return new Point(x, y); }) ]; numVertices = transformedGeom.length; } if (f.type === 0) return null; const geomType = { [1]: GeomType.Point, [2]: GeomType.Line, [3]: GeomType.Polygon }[f.type]; const feature: ProtomapsFeature = { props: { ...(f.tags ?? {}) }, bbox, geomType, geom: transformedGeom, numVertices }; return feature; }) .filter((f): f is ProtomapsFeature => f !== null); }