UNPKG

terriajs

Version:

Geospatial data visualization platform.

636 lines (564 loc) 19 kB
import Point from "@mapbox/point-geometry"; import { VectorTile, VectorTileFeature } from "@mapbox/vector-tile"; import i18next from "i18next"; import Protobuf from "pbf"; import BoundingRectangle from "terriajs-cesium/Source/Core/BoundingRectangle"; import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2"; import Cartographic from "terriajs-cesium/Source/Core/Cartographic"; import Credit from "terriajs-cesium/Source/Core/Credit"; import DefaultProxy from "terriajs-cesium/Source/Core/DefaultProxy"; import defaultValue from "terriajs-cesium/Source/Core/defaultValue"; import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError"; import CesiumEvent from "terriajs-cesium/Source/Core/Event"; import Intersect from "terriajs-cesium/Source/Core/Intersect"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme"; import WindingOrder from "terriajs-cesium/Source/Core/WindingOrder"; import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo"; import ImageryProvider from "terriajs-cesium/Source/Scene/ImageryProvider"; import TileDiscardPolicy from "terriajs-cesium/Source/Scene/TileDiscardPolicy"; import URITemplate from "urijs/src/URITemplate"; import isDefined from "../../Core/isDefined"; import loadArrayBuffer from "../../Core/loadArrayBuffer"; import computeRingWindingOrder from "../Vector/computeRingWindingOrder"; import { ImageryProviderWithGridLayerSupport } from "../Leaflet/ImageryProviderLeafletGridLayer"; interface Coords { x: number; y: number; level: number; } interface SimpleStyle { fillStyle: string; strokeStyle: string; lineWidth: number; lineJoin: CanvasLineJoin; } interface MapboxVectorTileImageryProviderOptions { url: string; layerName: string; subdomains?: unknown[]; styleFunc: (feature: VectorTileFeature) => SimpleStyle | undefined; minimumZoom?: number; maximumZoom?: number; maximumNativeZoom?: number; rectangle?: Rectangle; uniqueIdProp: string; featureInfoFunc?: ( feature: VectorTileFeature ) => ImageryLayerFeatureInfo | undefined; credit?: Credit | string; } /** Note this has been deprecated in favour of ProtomapsImageryProvider */ export default class MapboxVectorTileImageryProvider implements ImageryProviderWithGridLayerSupport { private readonly _uriTemplate: uri.URITemplate; private readonly _layerName: string; private readonly _subdomains: string[]; private readonly _styleFunc: ( feature: VectorTileFeature ) => SimpleStyle | undefined; private readonly _tilingScheme: WebMercatorTilingScheme; private readonly _tileWidth: number; private readonly _tileHeight: number; private readonly _minimumLevel: number; private readonly _maximumLevel: number; private readonly _maximumNativeLevel: number; private readonly _rectangle: Rectangle; private readonly _uniqueIdProp: string; private readonly _featureInfoFunc?: ( feature: VectorTileFeature ) => ImageryLayerFeatureInfo | undefined; private readonly _errorEvent = new CesiumEvent(); private readonly _ready = true; private readonly _credit?: Credit | string; constructor(options: MapboxVectorTileImageryProviderOptions) { this._uriTemplate = new URITemplate(options.url); this._layerName = options.layerName; this._subdomains = defaultValue(options.subdomains, []); this._styleFunc = options.styleFunc; this._tilingScheme = new WebMercatorTilingScheme(); this._tileWidth = 256; this._tileHeight = 256; this._minimumLevel = defaultValue(options.minimumZoom, 0); this._maximumLevel = defaultValue(options.maximumZoom, Infinity); this._maximumNativeLevel = defaultValue( options.maximumNativeZoom, this._maximumLevel ); this._rectangle = isDefined(options.rectangle) ? Rectangle.intersection( options.rectangle, this._tilingScheme.rectangle ) || this._tilingScheme.rectangle : this._tilingScheme.rectangle; this._uniqueIdProp = options.uniqueIdProp; this._featureInfoFunc = options.featureInfoFunc; //this._featurePicking = options.featurePicking; // Check the number of tiles at the minimum level. If it's more than four, // throw an exception, because starting at the higher minimum // level will cause too many tiles to be downloaded and rendered. const swTile = this._tilingScheme.positionToTileXY( Rectangle.southwest(this._rectangle), this._minimumLevel ); const neTile = this._tilingScheme.positionToTileXY( Rectangle.northeast(this._rectangle), this._minimumLevel ); const tileCount = (Math.abs(neTile.x - swTile.x) + 1) * (Math.abs(neTile.y - swTile.y) + 1); if (tileCount > 4) { throw new DeveloperError( i18next.t("map.mapboxVectorTileImageryProvider.moreThanFourTiles", { tileCount: tileCount }) ); } this._errorEvent = new CesiumEvent(); this._ready = true; this._credit = options.credit; } get url() { return this._uriTemplate.expression; } get tileWidth() { return this._tileWidth; } get tileHeight() { return this._tileHeight; } get maximumLevel() { return this._maximumLevel; } get minimumLevel() { return this._minimumLevel; } get tilingScheme() { return this._tilingScheme; } get rectangle() { return this._rectangle; } get errorEvent() { return this._errorEvent; } get ready() { return this._ready; } get defaultNightAlpha() { return undefined; } get defaultDayAlpha() { return undefined; } get hasAlphaChannel() { return true; } get credit(): Credit { let credit = this._credit; if (credit === undefined) { return <any>undefined; } else if (typeof credit === "string") { credit = new Credit(credit); } return credit; } get defaultAlpha(): number { return <any>undefined; } get defaultBrightness(): number { return <any>undefined; } get defaultContrast(): number { return <any>undefined; } get defaultGamma(): number { return <any>undefined; } get defaultHue(): number { return <any>undefined; } get defaultSaturation(): number { return <any>undefined; } get defaultMagnificationFilter(): any { return undefined; } get defaultMinificationFilter(): any { return undefined; } get proxy(): DefaultProxy { return <any>undefined; } get readyPromise(): Promise<boolean> { return Promise.resolve(true); } get tileDiscardPolicy(): TileDiscardPolicy { return <any>undefined; } getTileCredits(x: number, y: number, level: number): Credit[] { return []; } _getSubdomain(x: number, y: number, level: number) { if (this._subdomains.length === 0) { return undefined; } else { const index = (x + y + level) % this._subdomains.length; return this._subdomains[index]; } } _buildImageUrl(x: number, y: number, level: number) { return this._uriTemplate.expand({ z: level.toString(), x: x.toString(), y: y.toString(), s: this._getSubdomain(x, y, level) }); } requestImage(x: number, y: number, level: number) { const canvas = document.createElement("canvas"); canvas.width = this._tileWidth; canvas.height = this._tileHeight; return this.requestImageForCanvas(x, y, level, canvas); } requestImageForCanvas( x: number, y: number, level: number, canvas: HTMLCanvasElement ) { const requestedTile = { x: x, y: y, level: level }; let nativeTile: Coords; // The level, x & y of the tile used to draw the requestedTile // Check whether to use a native tile or overzoom the largest native tile if (level > this._maximumNativeLevel) { // Determine which native tile to use const levelDelta = level - this._maximumNativeLevel; nativeTile = { x: x >> levelDelta, y: y >> levelDelta, level: this._maximumNativeLevel }; } else { nativeTile = requestedTile; } const url = this._buildImageUrl( nativeTile.x, nativeTile.y, nativeTile.level ); return loadArrayBuffer(url.toString()).then((data: any) => { return this._drawTile( requestedTile, nativeTile, new VectorTile(new Protobuf(data)), canvas ); }); } _drawTile( requestedTile: Coords, nativeTile: Coords, tile: VectorTile, canvas: HTMLCanvasElement ) { const layer = tile.layers[this._layerName]; if (!isDefined(layer)) { return canvas; // return blank canvas for blank tile } const context = canvas.getContext("2d"); if (context === null) { return canvas; } context.strokeStyle = "black"; context.lineWidth = 1; let pos; let extentFactor = canvas.width / (<any>layer).extent; // Vector tile works with extent [0, 4095], but canvas is only [0,255] // Features for (let i = 0; i < layer.length; i++) { const feature = layer.feature(i); if (VectorTileFeature.types[feature.type] === "Polygon") { const style = this._styleFunc(feature); if (!style) continue; context.fillStyle = style.fillStyle; context.strokeStyle = style.strokeStyle; context.lineWidth = style.lineWidth; context.lineJoin = style.lineJoin; context.beginPath(); let coordinates; if (nativeTile.level !== requestedTile.level) { // Overzoom feature const bbox = feature.bbox(); // [w, s, e, n] bounding box const featureRect = new BoundingRectangle( bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1] ); const levelDelta = requestedTile.level - nativeTile.level; const size = layer.extent >> levelDelta; if (size < 16) { // Tile has less less detail than 16x16 throw new DeveloperError( i18next.t("map.mapboxVectorTileImageryProvider.maxLevelError") ); } const x1 = size * (requestedTile.x - (nativeTile.x << levelDelta)); // const y1 = size * (requestedTile.y - (nativeTile.y << levelDelta)); const tileRect = new BoundingRectangle(x1, y1, size, size); if ( BoundingRectangle.intersect(featureRect, tileRect) === Intersect.OUTSIDE ) { continue; } extentFactor = canvas.width / size; coordinates = overzoomGeometry( feature.loadGeometry(), nativeTile, size, requestedTile ); } else { coordinates = feature.loadGeometry(); } // Polygon rings for (let i2 = 0; i2 < coordinates.length; i2++) { pos = coordinates[i2][0]; context.moveTo(pos.x * extentFactor, pos.y * extentFactor); // Polygon ring points for (let j = 1; j < coordinates[i2].length; j++) { pos = coordinates[i2][j]; context.lineTo(pos.x * extentFactor, pos.y * extentFactor); } } context.stroke(); context.fill(); } else { console.log( "Unexpected geometry type: " + feature.type + " in region map on tile " + [requestedTile.level, requestedTile.x, requestedTile.y].join("/") ); } } return canvas; } pickFeatures( x: number, y: number, level: number, longitude: number, latitude: number ): Promise<ImageryLayerFeatureInfo[]> { let nativeTile: Coords; let levelDelta: number; const requestedTile = { x: x, y: y, level: level }; // Check whether to use a native tile or overzoom the largest native tile if (level > this._maximumNativeLevel) { // Determine which native tile to use levelDelta = level - this._maximumNativeLevel; nativeTile = { x: x >> levelDelta, y: y >> levelDelta, level: this._maximumNativeLevel }; } else { nativeTile = { x: x, y: y, level: level }; } const that = this; const url = this._buildImageUrl( nativeTile.x, nativeTile.y, nativeTile.level ); return loadArrayBuffer(url.toString()).then((data: any) => { const layer = new VectorTile(new Protobuf(data)).layers[that._layerName]; if (!isDefined(layer)) { return []; // return empty list of features for empty tile } const vt_range = [0, (layer.extent >> levelDelta) - 1]; const boundRect = that._tilingScheme.tileXYToNativeRectangle(x, y, level); const x_range = [boundRect.west, boundRect.east]; const y_range = [boundRect.north, boundRect.south]; const map = function ( pos: Cartesian2, in_x_range: number[], in_y_range: number[], out_x_range: number[], out_y_range: number[] ) { const offset = new Cartesian2(); Cartesian2.subtract( pos, new Cartesian2(in_x_range[0], in_y_range[0]), offset ); // Offset of point from bottom left corner of bounding box const scale = new Cartesian2( (out_x_range[1] - out_x_range[0]) / (in_x_range[1] - in_x_range[0]), (out_y_range[1] - out_y_range[0]) / (in_y_range[1] - in_y_range[0]) ); return Cartesian2.add( Cartesian2.multiplyComponents(offset, scale, new Cartesian2()), new Cartesian2(out_x_range[0], out_y_range[0]), new Cartesian2() ); }; let pos = Cartesian2.fromCartesian3( that._tilingScheme.projection.project( new Cartographic(longitude, latitude) ) ); pos = map(pos, x_range, y_range, vt_range, vt_range); const point = new Point(pos.x, pos.y); const features = []; for (let i = 0; i < layer.length; i++) { const feature = layer.feature(i); if ( VectorTileFeature.types[feature.type] === "Polygon" && isFeatureClicked( overzoomGeometry( feature.loadGeometry(), nativeTile, layer.extent >> levelDelta, requestedTile ), point ) ) { if (isDefined(this._featureInfoFunc)) { const featureInfo = this._featureInfoFunc(feature); if (isDefined(featureInfo)) { features.push(featureInfo); } } } } return features; }); } createHighlightImageryProvider(regionUniqueID: string) { const that = this; const styleFunc = function (feature: any) { if (regionUniqueID === feature.properties[that._uniqueIdProp]) { // No fill, but same style border as the regions, just thicker const regionStyling = that._styleFunc(feature); if (isDefined(regionStyling)) { regionStyling.fillStyle = "rgba(0,0,0,0)"; regionStyling.lineJoin = "round"; regionStyling.lineWidth = Math.floor( 1.5 * defaultValue(regionStyling.lineWidth, 1) + 1 ); return regionStyling; } } return undefined; }; const imageryProvider = new MapboxVectorTileImageryProvider({ url: this._uriTemplate.expression, layerName: this._layerName, subdomains: this._subdomains, rectangle: this._rectangle, minimumZoom: this._minimumLevel, maximumNativeZoom: this._maximumNativeLevel, maximumZoom: this._maximumLevel, uniqueIdProp: this._uniqueIdProp, styleFunc: styleFunc, credit: "" }); imageryProvider.pickFeatures = function () { return Promise.resolve([]); }; // Turn off feature picking return imageryProvider; } } // Use x,y,level vector tile to produce imagery for newX,newY,newLevel function overzoomGeometry( rings: Point[][], nativeTile: Coords, newExtent: number, newTile: Coords ): Point[][] { const diffZ = newTile.level - nativeTile.level; if (diffZ === 0) { return rings; } else { const newRings = []; // (offsetX, offsetY) is the (0,0) of the new tile const offsetX = newExtent * (newTile.x - (nativeTile.x << diffZ)); const offsetY = newExtent * (newTile.y - (nativeTile.y << diffZ)); for (let i = 0; i < rings.length; i++) { const ring = []; for (let i2 = 0; i2 < rings[i].length; i2++) { ring.push(rings[i][i2].sub(new Point(offsetX, offsetY))); } newRings.push(ring); } return newRings; } } function isExteriorRing(ring: Point[]) { // Normally an exterior ring would be clockwise but because these coordinates are in "canvas space" the ys are inverted // hence check for counter-clockwise ring const windingOrder = computeRingWindingOrder(ring) as unknown as WindingOrder; return windingOrder === WindingOrder.COUNTER_CLOCKWISE; } // Adapted from npm package "point-in-polygon" by James Halliday // Licence included in LICENSE.md function inside(point: Point, vs: Point[]) { // ray-casting algorithm based on // http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html const x = point.x, y = point.y; let inside = false; for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) { const xi = vs[i].x, yi = vs[i].y; const xj = vs[j].x, yj = vs[j].y; const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; } // According to the Mapbox Vector Tile specifications, a polygon consists of one exterior ring followed by 0 or more interior rings. Therefore: // for each ring: // if point in ring: // for each interior ring (following the exterior ring): // check point in interior ring // if point not in any interior rings, feature is clicked function isFeatureClicked(rings: Point[][], point: Point) { for (let i = 0; i < rings.length; i++) { if (inside(point, rings[i])) { // Point is in an exterior ring // Check whether point is in any interior rings let inInteriorRing = false; while (i + 1 < rings.length && !isExteriorRing(rings[i + 1])) { i++; if (!inInteriorRing && inside(point, rings[i])) { inInteriorRing = true; // Don't break. Still need to iterate over the rest of the interior rings but don't do point-in-polygon tests on those } } // Point is in exterior ring, but not in any interior ring. Therefore point is in the feature region if (!inInteriorRing) { return true; } } } return false; }