import type { Credit, Event, ImageryLayerFeatureInfo, ImageryProvider, ImageryTypes, Proxy, Rectangle, Request, TileDiscardPolicy, TilingScheme, } from 'cesium'; import type {UrlFunction} from 'ol/Tile.js'; import MVT from 'ol/format/MVT.js'; import {get as getProjection} from 'ol/proj.js'; import {toContext} from 'ol/render.js'; import RenderFeature from 'ol/render/Feature.js'; import LRUCache from 'ol/structs/LRUCache.js'; import Stroke from 'ol/style/Stroke.js'; import Style, {type StyleFunction} from 'ol/style/Style.js'; import {getForProjection as getTilegridForProjection} from 'ol/tilegrid.js'; import {createFromTemplates as createTileUrlFunctions} from 'ol/tileurlfunction.js'; import {createEmptyCanvas} from './core/OLImageryProvider.js'; export interface MVTOptions { urls: string[]; rectangle: Rectangle; credit: Credit; styleFunction: StyleFunction; cacheSize?: number; featureCache?: LRUCache>; minimumLevel: number; } const format = new MVT({ featureClass: RenderFeature, }); const styles = [ new Style({ stroke: new Stroke({ color: 'blue', width: 2, }), }), ]; export default class MVTImageryProvider implements ImageryProvider { private urls: string[]; private emptyCanvas_: HTMLCanvasElement = createEmptyCanvas(); private emptyCanvasPromise_: Promise = Promise.resolve( this.emptyCanvas_, ); private tilingScheme_ = new Cesium.WebMercatorTilingScheme(); private ready_ = true; private rectangle_: Rectangle; private tileRectangle_: Rectangle; readonly tileWidth = 256; readonly tileHeight = 256; readonly maximumLevel = 20; private minimumLevel_ = 0; get minimumLevel(): number { return this.minimumLevel_; } private featureCache: LRUCache>; private tileCache: LRUCache>; private tileFunction_: UrlFunction; private styleFunction_: StyleFunction; private projection_ = getProjection('EPSG:3857'); /** * When true, this model is ready to render, i.e., the external binary, image, * and shader files were downloaded and the WebGL resources were created. */ get ready(): boolean { return this.ready_; } /** * Gets the rectangle, in radians, of the imagery provided by the instance. */ get rectangle() { return this.rectangle_; } /** * Gets the tiling scheme used by the provider. */ get tilingScheme(): TilingScheme { return this.tilingScheme_; } /** * Gets an event that is raised when the imagery provider encounters an asynchronous error. By subscribing * to the event, you will be notified of the error and can potentially recover from it. Event listeners * are passed an instance of {@link Cesium.TileProviderError}. */ readonly errorEvent: Event = new Cesium.Event(); /** * Gets the credit to display when this imagery provider is active. Typically this is used to credit * the source of the imagery. */ readonly credit: Credit; getTileCredits(x: number, y: number, level: number): Credit[] { return []; } /** * Gets the proxy used by this provider. */ readonly proxy: Proxy; get _ready(): boolean { return this.ready_; } /** * Gets the tile discard policy. If not undefined, the discard policy is responsible * for filtering out "missing" tiles via its shouldDiscardImage function. If this function * returns undefined, no tiles are filtered. */ get tileDiscardPolicy(): TileDiscardPolicy { return undefined; } // FIXME: this might be exposed /** * Gets a value indicating whether or not the images provided by this imagery provider * include an alpha channel. If this property is false, an alpha channel, if present, will * be ignored. If this property is true, any images without an alpha channel will be treated * as if their alpha is 1.0 everywhere. When this property is false, memory usage * and texture upload time are reduced. */ get hasAlphaChannel() { return true; } // FIXME: this could be implemented by proxying to OL /** * Asynchronously determines what features, if any, are located at a given longitude and latitude within * a tile. * This function is optional, so it may not exist on all ImageryProviders. * @param x The tile X coordinate. * @param y The tile Y coordinate. * @param level The tile level. * @param longitude The longitude at which to pick features. * @param latitude The latitude at which to pick features. * @return A promise for the picked features that will resolve when the asynchronous * picking completes. The resolved value is an array of {@link ImageryLayerFeatureInfo} * instances. The array may be empty if no features are found at the given location. * It may also be undefined if picking is not supported. */ pickFeatures( x: number, y: number, level: number, longitude: number, latitude: number, ): Promise | undefined { return undefined; } constructor(options: MVTOptions) { this.urls = options.urls; this.rectangle_ = options.rectangle || this.tilingScheme.rectangle; this.credit = options.credit; this.styleFunction_ = options.styleFunction || (() => styles); this.tileRectangle_ = new Cesium.Rectangle(); // to avoid too frequent cache grooming we allow x2 capacity const cacheSize = options.cacheSize !== undefined ? options.cacheSize : 50; this.tileCache = new LRUCache(cacheSize); this.featureCache = options.featureCache || new LRUCache(cacheSize); this.minimumLevel_ = options.minimumLevel || 0; const tileGrid = getTilegridForProjection(this.projection_); this.tileFunction_ = createTileUrlFunctions(this.urls, tileGrid); } private getTileFeatures( z: number, x: number, y: number, ): Promise { const cacheKey = this.getCacheKey_(z, x, y); let promise; if (this.featureCache.containsKey(cacheKey)) { promise = this.featureCache.get(cacheKey); } if (!promise) { const url = this.getUrl_(z, x, y); promise = fetch(url) .then((r) => (r.ok ? r : Promise.reject(r))) .then((r) => r.arrayBuffer()) .then((buffer) => this.readFeaturesFromBuffer(buffer)); this.featureCache.set(cacheKey, promise); if (this.featureCache.getCount() > 2 * this.featureCache.highWaterMark) { while (this.featureCache.canExpireCache()) { this.featureCache.pop(); } } } return promise; } readFeaturesFromBuffer(buffer: ArrayBuffer): RenderFeature[] { const features = format.readFeatures(buffer) as RenderFeature[]; const scaleFactor = this.tileWidth / 4096; features.forEach((f) => { const flatCoordinates = f.getFlatCoordinates(); for (let i = 0; i < flatCoordinates.length; ++i) { flatCoordinates[i] *= scaleFactor; } }); return features; } private getUrl_(z: number, x: number, y: number): string { // FIXME: probably we should not pass 1 as pixelRatio const url = this.tileFunction_([z, x, y], 1, this.projection_); return url; } private getCacheKey_(z: number, x: number, y: number) { return `${z}_${x}_${y}`; } requestImage( x: number, y: number, z: number, request?: Request, ): Promise | undefined { if (z < this.minimumLevel_) { return this.emptyCanvasPromise_; } try { const cacheKey = this.getCacheKey_(z, x, y); let promise; if (this.tileCache.containsKey(cacheKey)) { promise = this.tileCache.get(cacheKey); } if (!promise) { promise = this.getTileFeatures(z, x, y).then((features) => { // FIXME: here we suppose the 2D projection is in meters this.tilingScheme.tileXYToNativeRectangle( x, y, z, this.tileRectangle_, ); const resolution = (this.tileRectangle_.east - this.tileRectangle_.west) / this.tileWidth; return this.rasterizeFeatures( features, this.styleFunction_, resolution, ); }); this.tileCache.set(cacheKey, promise); if (this.tileCache.getCount() > 2 * this.tileCache.highWaterMark) { while (this.tileCache.canExpireCache()) { this.tileCache.pop(); } } } return promise; } catch (e) { console.trace(e); // FIXME: open PR on Cesium to fix incorrect typing // @ts-ignore this.errorEvent.raiseEvent('could not render pbf to tile', e); } } rasterizeFeatures( features: RenderFeature[], styleFunction: StyleFunction, resolution: number, ): HTMLCanvasElement { const canvas = document.createElement('canvas'); const vectorContext = toContext(canvas.getContext('2d'), { size: [this.tileWidth, this.tileHeight], }); features.forEach((f) => { const styles = styleFunction(f, resolution); if (styles) { if (Array.isArray(styles)) { styles.forEach((style) => { vectorContext.setStyle(style); vectorContext.drawGeometry(f); }); } else { vectorContext.setStyle(styles); vectorContext.drawGeometry(f); } } }); return canvas; } }