UNPKG

terriajs

Version:

Geospatial data visualization platform.

500 lines (439 loc) 17.1 kB
import Point from "@mapbox/point-geometry"; import { isEmpty } from "lodash-es"; import { action, makeObservable } from "mobx"; import { LabelRule, Labelers, LineSymbolizer, PaintRule, PmtilesSource, PreparedTile, TileCache, View, ZxySource, paint } from "protomaps-leaflet"; import Cartographic from "terriajs-cesium/Source/Core/Cartographic"; import Credit from "terriajs-cesium/Source/Core/Credit"; import CesiumEvent from "terriajs-cesium/Source/Core/Event"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import Request from "terriajs-cesium/Source/Core/Request"; import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme"; import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo"; import { FeatureCollectionWithCrs } from "../../Core/GeoJson"; import TerriaError from "../../Core/TerriaError"; import isDefined from "../../Core/isDefined"; import { FEATURE_ID_PROP as GEOJSON_FEATURE_ID_PROP } from "../../ModelMixins/GeojsonMixin"; import { default as TerriaFeature } from "../../Models/Feature/Feature"; import Terria from "../../Models/Terria"; import { ImageryProviderWithGridLayerSupport } from "../Leaflet/ImageryProviderLeafletGridLayer"; import { ProtomapsArcGisPbfSource } from "../Vector/Protomaps/ProtomapsArcGisPbfSource"; import { GEOJSON_SOURCE_LAYER_NAME, ProtomapsGeojsonSource } from "../Vector/Protomaps/ProtomapsGeojsonSource"; export const LAYER_NAME_PROP = "__LAYERNAME"; interface Coords { z: number; x: number; y: number; } /** Data object can be: * - URL of geojson, pmtiles or pbf template (eg `something.com/{z}/{x}/{y}.pbf`) * - GeoJsonObject object * -Source object (PmtilesSource | ZxySource | ProtomapsGeojsonSource) */ export type ProtomapsData = string | FeatureCollectionWithCrs | Source; interface Options { terria: Terria; /** This must be defined to support pickedFeatures in share links */ id?: string; data: ProtomapsData; minimumZoom?: number; maximumZoom?: number; maximumNativeZoom?: number; rectangle?: Rectangle; credit?: Credit | string; paintRules: PaintRule[]; labelRules: LabelRule[]; /** The name of the property that is a unique ID for features */ idProperty?: string; processPickedFeatures?: ( features: ImageryLayerFeatureInfo[] ) => Promise<ImageryLayerFeatureInfo[]>; } type Source = | PmtilesSource | ZxySource | ProtomapsGeojsonSource | ProtomapsArcGisPbfSource; /** Tile size in pixels (for canvas and geojson-vt) */ export const PROTOMAPS_DEFAULT_TILE_SIZE = 256; /** Buffer (in pixels) used when rendering (and generating - through geojson-vt) vector tiles */ export const PROTOMAPS_TILE_BUFFER = 32; /** Tile cache tile size for protomaps-leaflet */ const TILE_CACHE_TILE_SIZE = 1024; export default class ProtomapsImageryProvider implements ImageryProviderWithGridLayerSupport { private readonly terria: Terria; // Imagery provider properties readonly tilingScheme: WebMercatorTilingScheme; readonly tileWidth: number; readonly tileHeight: number; readonly minimumLevel: number; /** This is used to fail requests for levels below softMinimumLevel, as setting minimumLevel to higher than 0 (with no rectangle provided), will result in many tiles being requested. */ readonly softMinimumLevel?: number; readonly maximumLevel: number; readonly rectangle: Rectangle; readonly errorEvent = new CesiumEvent(); readonly ready = true; readonly credit: Credit; /** This is only used for Terria feature picking - as we track ImageryProvider feature picking by url (See PickedFeatures/Cesium._attachProviderCoordHooks). This URL is never called. * This is set using the `id` property in the constructor options */ readonly url?: string; // Set values to please poor cesium types readonly defaultNightAlpha = undefined; readonly defaultDayAlpha = undefined; readonly hasAlphaChannel = true; readonly defaultAlpha = undefined as any; readonly defaultBrightness = undefined as any; readonly defaultContrast = undefined as any; readonly defaultGamma = undefined as any; readonly defaultHue = undefined as any; readonly defaultSaturation = undefined as any; readonly defaultMagnificationFilter = undefined as any; readonly defaultMinificationFilter = undefined as any; readonly proxy = undefined as any; readonly readyPromise = Promise.resolve(true); readonly tileDiscardPolicy = undefined as any; // Protomaps properties /** Data object from constructor options (this is transformed into `source`) */ private readonly data: ProtomapsData; private readonly labelers: Labelers; private readonly view: View | undefined; private readonly processPickedFeatures?: ( features: ImageryLayerFeatureInfo[] ) => Promise<ImageryLayerFeatureInfo[]>; readonly maximumNativeZoom: number; readonly idProperty: string; readonly source: Source; readonly paintRules: PaintRule[]; readonly labelRules: LabelRule[]; constructor(options: Options) { makeObservable(this); this.data = options.data; this.terria = options.terria; this.tilingScheme = new WebMercatorTilingScheme(); this.tileWidth = PROTOMAPS_DEFAULT_TILE_SIZE; this.tileHeight = PROTOMAPS_DEFAULT_TILE_SIZE; // Note we leave minimumLevel at 0, and then we fail requests for levels below softMinimumLevel (see softMinimumLevel) this.minimumLevel = 0; this.softMinimumLevel = options.minimumZoom; this.maximumLevel = options.maximumZoom ?? 24; this.maximumNativeZoom = options.maximumNativeZoom ?? this.maximumLevel; this.rectangle = isDefined(options.rectangle) ? Rectangle.intersection( options.rectangle, this.tilingScheme.rectangle ) || this.tilingScheme.rectangle : this.tilingScheme.rectangle; this.errorEvent = new CesiumEvent(); this.url = options.id; this.ready = true; this.credit = typeof options.credit === "string" ? new Credit(options.credit) : (options.credit as Credit); // Protomaps this.paintRules = options.paintRules; this.labelRules = options.labelRules; this.idProperty = options.idProperty ?? "FID"; // Generate protomaps source based on this.data // - URL of pmtiles, geojson or pbf files if (typeof this.data === "string") { // Note: the `base` passed to URL is not relevant as we only use the // parsed path. It still needs to be passed so that URL won't throw an // error when passing relative URLs like /proxy/something. const path = new URL(this.data, "http://example").pathname; if (path.endsWith(".pmtiles")) { this.source = new PmtilesSource(this.data, false); const cache = new TileCache(this.source, TILE_CACHE_TILE_SIZE); this.view = new View(cache, this.maximumNativeZoom, 2); } else if (path.endsWith(".json") || path.endsWith(".geojson")) { this.source = new ProtomapsGeojsonSource(this.data); } else { this.source = new ZxySource(this.data, false); const cache = new TileCache(this.source, TILE_CACHE_TILE_SIZE); this.view = new View(cache, this.maximumNativeZoom, 2); } } // Source object else if ( this.data instanceof ProtomapsGeojsonSource || this.data instanceof PmtilesSource || this.data instanceof ZxySource || this.data instanceof ProtomapsArcGisPbfSource ) { this.source = this.data; } // - GeoJsonObject object else { this.source = new ProtomapsGeojsonSource(this.data); } const labelersCanvasContext = document .createElement("canvas") .getContext("2d"); if (!labelersCanvasContext) throw TerriaError.from("Failed to create labelersCanvasContext"); this.labelers = new Labelers( labelersCanvasContext, this.labelRules, 16, () => undefined ); this.processPickedFeatures = options.processPickedFeatures; } getTileCredits(_x: number, _y: number, _level: number): Credit[] { return []; } async requestImage(x: number, y: number, level: number, request?: Request) { const canvas = document.createElement("canvas"); canvas.width = this.tileWidth; canvas.height = this.tileHeight; return await this.requestImageForCanvas(x, y, level, canvas, request); } async requestImageForCanvas( x: number, y: number, level: number, canvas: HTMLCanvasElement, request?: Request ) { if (isDefined(this.softMinimumLevel) && level < this.softMinimumLevel) throw TerriaError.from( `Level: ${level} is below softMinimumLevel (=${this.softMinimumLevel})` ); const coords: Coords = { z: level, x, y }; // Adapted from https://github.com/protomaps/protomaps.js/blob/master/src/frontends/leaflet.ts let tile: PreparedTile; // Get PreparedTile from source or view // Here we need a little bit of extra logic for the ProtomapsGeojsonSource if ( this.source instanceof ProtomapsGeojsonSource || this.source instanceof ProtomapsArcGisPbfSource ) { const data = await this.source.get(coords, this.tileHeight, request); tile = { data: data, z: coords.z, dataTile: coords, scale: 1, origin: new Point( coords.x * PROTOMAPS_DEFAULT_TILE_SIZE, coords.y * PROTOMAPS_DEFAULT_TILE_SIZE ), dim: this.tileWidth }; } else if (this.view) { tile = await this.view.getDisplayTile(coords); } else { throw TerriaError.from( `Failed to get tile - no view or appropriate source in ProtomapsImageryProvider` ); } const tileMap = new Map<string, PreparedTile[]>().set("", [tile]); this.labelers.add(coords.z, tileMap); const labelData = this.labelers.getIndex(tile.z); const bbox = { minX: PROTOMAPS_DEFAULT_TILE_SIZE * coords.x - PROTOMAPS_TILE_BUFFER, minY: PROTOMAPS_DEFAULT_TILE_SIZE * coords.y - PROTOMAPS_TILE_BUFFER, maxX: PROTOMAPS_DEFAULT_TILE_SIZE * (coords.x + 1) + PROTOMAPS_TILE_BUFFER, maxY: PROTOMAPS_DEFAULT_TILE_SIZE * (coords.y + 1) + PROTOMAPS_TILE_BUFFER }; const origin = new Point( PROTOMAPS_DEFAULT_TILE_SIZE * coords.x, PROTOMAPS_DEFAULT_TILE_SIZE * coords.y ); const ctx = canvas.getContext("2d"); if (!ctx) throw TerriaError.from("Failed to get canvas context"); ctx.setTransform( this.tileWidth / PROTOMAPS_DEFAULT_TILE_SIZE, 0, 0, this.tileHeight / PROTOMAPS_DEFAULT_TILE_SIZE, 0, 0 ); ctx.clearRect( 0, 0, PROTOMAPS_DEFAULT_TILE_SIZE, PROTOMAPS_DEFAULT_TILE_SIZE ); if (labelData) paint( ctx, coords.z, tileMap, labelData, this.paintRules, bbox, origin, false, "" ); return canvas; } async pickFeatures( x: number, y: number, level: number, longitude: number, latitude: number ): Promise<ImageryLayerFeatureInfo[]> { const featureInfos: ImageryLayerFeatureInfo[] = []; // If view is set - this means we are using actual vector tiles (that is not GeoJson object) // So we use this.view.queryFeatures if (this.view) { try { // Make sure tile is loaded, this will use cache if tile is already loaded await this.view.getDisplayTile({ x, y, z: level }); } catch (e) { TerriaError.from(e, "Error while picking features").log(); return []; } // Get list of vector tile layers which are rendered const renderedLayers = [...this.paintRules, ...this.labelRules].map( (r) => r.dataLayer ); this.view .queryFeatures( CesiumMath.toDegrees(longitude), CesiumMath.toDegrees(latitude), level, 1 ) .forEach((f) => { // Only create FeatureInfo for visible features with properties if ( !f.feature.props || isEmpty(f.feature.props) || !renderedLayers.includes(f.layerName) ) return; const featureInfo = new ImageryLayerFeatureInfo(); // Add Layer name property featureInfo.properties = Object.assign( { [LAYER_NAME_PROP]: f.layerName }, f.feature.props ?? {} ); featureInfo.position = new Cartographic(longitude, latitude); featureInfo.configureDescriptionFromProperties(f.feature.props); featureInfo.configureNameFromProperties(f.feature.props); featureInfos.push(featureInfo); }); // No view is set and we have geoJSON object // So we pick features manually } else if ( this.source instanceof ProtomapsGeojsonSource || this.source instanceof ProtomapsArcGisPbfSource ) { featureInfos.push( ...(await this.source.pickFeatures(x, y, level, longitude, latitude)) ); } if (this.processPickedFeatures) { return await this.processPickedFeatures(featureInfos); } return featureInfos; } private clone(options?: Partial<Options>) { let data = options?.data; // To clone data/source, we want to minimize any unnecessary processing if (!data) { // These can be passed straight in without processing if ( typeof this.data === "string" || this.data instanceof PmtilesSource || this.data instanceof ProtomapsArcGisPbfSource ) { data = this.data; // We can't just clone ZxySource objects, so just pass in URL } else if (this.data instanceof ZxySource) { data = this.data.url; // If ProtomapsGeojsonSource was passed into data, create new one and copy over tileIndex } else if (this.data instanceof ProtomapsGeojsonSource) { if (this.data.geojsonObject) { data = new ProtomapsGeojsonSource(this.data.geojsonObject); // Copy over tileIndex so it doesn't have to be re-processed data.tileIndex = this.data.tileIndex; } // If GeoJson FeatureCollection was passed into data (this.data), and the source is ProtomapsGeojsonSource // create a ProtomapsGeojsonSource with the GeoJson and copy over tileIndex } else if (this.source instanceof ProtomapsGeojsonSource) { data = new ProtomapsGeojsonSource(this.data); // Copy over tileIndex so it doesn't have to be re-processed data.tileIndex = this.source.tileIndex; } } if (!data) return; return new ProtomapsImageryProvider({ terria: options?.terria ?? this.terria, id: options?.id ?? this.url, data, // Note we use softMinimumLevel here, the imagery provider minimum level is always 0 minimumZoom: options?.minimumZoom ?? this.softMinimumLevel, maximumZoom: options?.maximumZoom ?? this.maximumLevel, maximumNativeZoom: options?.maximumNativeZoom ?? this.maximumNativeZoom, rectangle: options?.rectangle ?? this.rectangle, credit: options?.credit ?? this.credit, paintRules: options?.paintRules ?? this.paintRules, labelRules: options?.labelRules ?? this.labelRules, processPickedFeatures: options?.processPickedFeatures ?? this.processPickedFeatures }); } /** Clones ImageryProvider, and sets paintRules to highlight picked features */ @action createHighlightImageryProvider( feature: TerriaFeature ): ProtomapsImageryProvider | undefined { // Depending on this.source, feature IDs might be FID (for actual vector tile sources) or they will use GEOJSON_FEATURE_ID_PROP let featureProp: string | undefined; // Similarly, feature layer name will be LAYER_NAME_PROP for mvt, whereas GeoJSON features will use the constant GEOJSON_SOURCE_LAYER_NAME let layerName: string | undefined; if (this.source instanceof ProtomapsGeojsonSource) { featureProp = GEOJSON_FEATURE_ID_PROP; layerName = GEOJSON_SOURCE_LAYER_NAME; } else if (this.source instanceof ProtomapsArcGisPbfSource) { featureProp = this.source.objectIdField; layerName = GEOJSON_SOURCE_LAYER_NAME; } else { featureProp = this.idProperty; layerName = feature.properties?.[LAYER_NAME_PROP]?.getValue(); } const featureId = feature.properties?.[featureProp]?.getValue(); if (isDefined(featureId) && isDefined(layerName)) { return this.clone({ labelRules: [], paintRules: [ { dataLayer: layerName, symbolizer: new LineSymbolizer({ color: this.terria.baseMapContrastColor, width: 4 }), minzoom: 0, maxzoom: Infinity, filter: (_zoom, feature) => feature.props?.[featureProp!] === featureId } ] }); } return; } }