terriajs
Version:
Geospatial data visualization platform.
701 lines (610 loc) • 21.8 kB
text/typescript
import Point from "@mapbox/point-geometry";
import bbox from "@turf/bbox";
import booleanIntersects from "@turf/boolean-intersects";
import circle from "@turf/circle";
import { Feature } from "@turf/helpers";
import i18next from "i18next";
import { cloneDeep } from "lodash-es";
import { action, observable, runInAction } from "mobx";
import {
Bbox,
Feature as ProtomapsFeature,
GeomType,
Labelers,
LabelRule,
LineSymbolizer,
painter,
PmtilesSource,
PreparedTile,
Rule as PaintRule,
TileCache,
TileSource,
View,
Zxy,
ZxySource
} from "protomaps";
import Cartographic from "terriajs-cesium/Source/Core/Cartographic";
import Credit from "terriajs-cesium/Source/Core/Credit";
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 CesiumMath from "terriajs-cesium/Source/Core/Math";
import Rectangle from "terriajs-cesium/Source/Core/Rectangle";
import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme";
import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo";
import filterOutUndefined from "../../Core/filterOutUndefined";
import isDefined from "../../Core/isDefined";
import TerriaError from "../../Core/TerriaError";
import {
FeatureCollectionWithCrs,
FEATURE_ID_PROP as GEOJSON_FEATURE_ID_PROP,
toFeatureCollection
} from "../../ModelMixins/GeojsonMixin";
import { default as TerriaFeature } from "../../Models/Feature/Feature";
import Terria from "../../Models/Terria";
import { ImageryProviderWithGridLayerSupport } from "../Leaflet/ImageryProviderLeafletGridLayer";
const geojsonvt = require("geojson-vt").default;
type GeojsonVtFeature = {
id: any;
type: GeomType;
geometry: [number, number][][] | [number, number][];
tags: any;
};
type GeojsonVtTile = {
features: GeojsonVtFeature[];
numPoints: number;
numSimplified: number;
numFeatures: number;
source: any;
x: number;
y: number;
z: number;
transformed: boolean;
minX: number;
minY: number;
maxX: number;
maxY: number;
};
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 | GeojsonSource)
*/
export type ProtomapsData = string | FeatureCollectionWithCrs | Source;
interface Options {
terria: Terria;
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;
}
/** Buffer (in pixels) used when rendering (and generating - through geojson-vt) vector tiles */
const BUF = 64;
/** Tile size in pixels (for canvas and geojson-vt) */
const tileSize = 256;
/** Extent (of coordinates) of tiles generated by geojson-vt */
const geojsonvtExtent = 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";
const LAYER_NAME_PROP = "__LAYERNAME";
export class GeojsonSource implements TileSource {
/** Data object from Options */
private readonly data: string | FeatureCollectionWithCrs;
/** Resolved geojsonObject (if applicable) */
.ref
geojsonObject: FeatureCollectionWithCrs | undefined;
/** Geojson-vt tileIndex (if applicable) */
tileIndex: Promise<any> | undefined;
constructor(url: string | FeatureCollectionWithCrs) {
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 | undefined;
if (typeof this.data === "string") {
result = toFeatureCollection(await (await fetch(this.data)).json());
} else {
result = this.data;
}
runInAction(() => (this.geojsonObject = result));
return geojsonvt(result, {
buffer: (BUF / tileSize) * geojsonvtExtent,
extent: geojsonvtExtent,
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) as GeojsonVtTile;
let result = new Map<string, ProtomapsFeature[]>();
const scale = tileSize / geojsonvtExtent;
if (tile && tile.features && tile.features.length > 0) {
result.set(
GEOJSON_SOURCE_LAYER_NAME,
// We have to transform feature objects from GeojsonVtTile to ProtomapsFeature
tile.features.map((f) => {
let transformedGeom: Point[][] = [];
let numVertices = 0;
// Calculate bbox
let bbox: Bbox = {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
};
// Multi geometry (eg polygon, multi-line string)
if (Array.isArray(f.geometry[0][0])) {
const geom = f.geometry as [number, number][][];
transformedGeom = geom.map((g1) =>
g1.map((g2) => {
g2 = [g2[0] * scale, g2[1] * scale];
if (bbox.minX > g2[0]) {
bbox.minX = g2[0];
}
if (bbox.maxX < g2[0]) {
bbox.maxX = g2[0];
}
if (bbox.minY > g2[1]) {
bbox.minY = g2[1];
}
if (bbox.maxY < g2[1]) {
bbox.maxY = g2[1];
}
return new Point(g2[0], g2[1]);
})
);
numVertices = transformedGeom.reduce<number>(
(count, current) => count + current.length,
0
);
}
// Flat geometry (line string, point)
else {
const geom = f.geometry as [number, number][];
transformedGeom = [
geom.map((g1) => {
g1 = [g1[0] * scale, g1[1] * scale];
if (bbox.minX > g1[0]) {
bbox.minX = g1[0];
}
if (bbox.maxX < g1[0]) {
bbox.maxX = g1[0];
}
if (bbox.minY > g1[1]) {
bbox.minY = g1[1];
}
if (bbox.maxY < g1[1]) {
bbox.maxY = g1[1];
}
return new Point(g1[0], g1[1]);
})
];
numVertices = transformedGeom.length;
}
const feature: ProtomapsFeature = {
props: f.tags,
bbox,
geomType: f.type,
geom: transformedGeom,
numVertices
};
return feature;
})
);
}
return result;
}
}
type Source = PmtilesSource | ZxySource | GeojsonSource;
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;
readonly maximumLevel: number;
readonly rectangle: Rectangle;
readonly errorEvent = new CesiumEvent();
readonly ready = true;
readonly credit: Credit;
// Set values to please poor cesium types
readonly defaultNightAlpha = undefined;
readonly defaultDayAlpha = undefined;
readonly hasAlphaChannel = true;
readonly defaultAlpha = <any>undefined;
readonly defaultBrightness = <any>undefined;
readonly defaultContrast = <any>undefined;
readonly defaultGamma = <any>undefined;
readonly defaultHue = <any>undefined;
readonly defaultSaturation = <any>undefined;
readonly defaultMagnificationFilter = undefined as any;
readonly defaultMinificationFilter = undefined as any;
readonly proxy = <any>undefined;
readonly readyPromise = Promise.resolve(true);
readonly tileDiscardPolicy = <any>undefined;
// Protomaps properties
/** Data object from constructor options (this is transformed into `source`) */
private readonly data: ProtomapsData;
readonly maximumNativeZoom: number;
private readonly labelers: Labelers;
private readonly view: View | undefined;
readonly idProperty: string;
readonly source: Source;
readonly paintRules: PaintRule[];
readonly labelRules: LabelRule[];
constructor(options: Options) {
this.data = options.data;
this.terria = options.terria;
this.tilingScheme = new WebMercatorTilingScheme();
this.tileWidth = tileSize;
this.tileHeight = tileSize;
this.minimumLevel = defaultValue(options.minimumZoom, 0);
this.maximumLevel = defaultValue(options.maximumZoom, 24);
this.maximumNativeZoom = defaultValue(
options.maximumNativeZoom,
this.maximumLevel
);
this.rectangle = isDefined(options.rectangle)
? Rectangle.intersection(
options.rectangle,
this.tilingScheme.rectangle
) || this.tilingScheme.rectangle
: this.tilingScheme.rectangle;
// 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 =
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") {
if (this.data.endsWith(".pmtiles")) {
this.source = new PmtilesSource(this.data, false);
let cache = new TileCache(this.source, 1024);
this.view = new View(cache, this.maximumNativeZoom, 2);
} else if (
this.data.endsWith(".json") ||
this.data.endsWith(".geojson")
) {
this.source = new GeojsonSource(this.data);
} else {
this.source = new ZxySource(this.data, false);
let cache = new TileCache(this.source, 1024);
this.view = new View(cache, this.maximumNativeZoom, 2);
}
}
// Source object
else if (
this.data instanceof GeojsonSource ||
this.data instanceof PmtilesSource ||
this.data instanceof ZxySource
) {
this.source = this.data;
}
// - GeoJsonObject object
else {
this.source = new GeojsonSource(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
);
}
getTileCredits(x: number, y: number, level: number): Credit[] {
return [];
}
async requestImage(x: number, y: number, level: number) {
const canvas = document.createElement("canvas");
canvas.width = this.tileWidth;
canvas.height = this.tileHeight;
return await this.requestImageForCanvas(x, y, level, canvas);
}
async requestImageForCanvas(
x: number,
y: number,
level: number,
canvas: HTMLCanvasElement
) {
try {
await this.renderTile({ x, y, z: level }, canvas);
} catch (e) {
console.log(e);
}
return canvas;
}
public async renderTile(coords: Coords, canvas: HTMLCanvasElement) {
// Adapted from https://github.com/protomaps/protomaps.js/blob/master/src/frontends/leaflet.ts
let tile: PreparedTile | undefined = undefined;
// Get PreparedTile from source or view
// Here we need a little bit of extra logic for the GeojsonSource
if (this.source instanceof GeojsonSource) {
const data = await this.source.get(coords, this.tileHeight);
tile = {
data: data,
z: coords.z,
data_tile: coords,
scale: 1,
origin: new Point(coords.x * 256, coords.y * 256),
dim: this.tileWidth
};
} else if (this.view) {
tile = await this.view.getDisplayTile(coords);
}
if (!tile) return;
const tileMap = new Map<string, PreparedTile[]>().set("", [tile]);
this.labelers.add(coords.z, tileMap);
let labelData = this.labelers.getIndex(tile.z);
const bbox = {
minX: 256 * coords.x - BUF,
minY: 256 * coords.y - BUF,
maxX: 256 * (coords.x + 1) + BUF,
maxY: 256 * (coords.y + 1) + BUF
};
const origin = new Point(256 * coords.x, 256 * coords.y);
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(this.tileWidth / 256, 0, 0, this.tileHeight / 256, 0, 0);
ctx.clearRect(0, 0, 256, 256);
if (labelData)
painter(
ctx,
coords.z,
tileMap,
labelData,
this.paintRules,
bbox,
origin,
false,
""
);
}
async pickFeatures(
x: number,
y: number,
level: number,
longitude: number,
latitude: number
): Promise<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) {
// Get list of vector tile layers which are rendered
const renderedLayers = [...this.paintRules, ...this.labelRules].map(
(r) => r.dataLayer
);
return filterOutUndefined(
this.view
.queryFeatures(
CesiumMath.toDegrees(longitude),
CesiumMath.toDegrees(latitude),
level
)
.map((f) => {
// Only create FeatureInfo for visible features with properties
if (
!f.feature.props ||
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);
return featureInfo;
})
);
// No view is set and we have geoJSON object
// So we pick features manually
} else if (
this.source instanceof GeojsonSource &&
this.source.geojsonObject
) {
// Create circle with 10 pixel radius to pick features
const buffer = circle(
[CesiumMath.toDegrees(longitude), CesiumMath.toDegrees(latitude)],
10 * this.terria.mainViewer.scale,
{
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
let features: Feature[] = this.source.geojsonObject.features;
const pickedFeatures: Feature[] = [];
for (let index = 0; index < features.length; index++) {
const feature = features[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
return pickedFeatures.map((f) => {
const featureInfo = new ImageryLayerFeatureInfo();
featureInfo.data = f;
featureInfo.properties = 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);
return featureInfo;
});
}
return [];
}
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) {
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 GeojsonSource was passed into data, create new one and copy over tileIndex
} else if (this.data instanceof GeojsonSource) {
if (this.data.geojsonObject) {
data = new GeojsonSource(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 GeojsonSource
// create a GeojsonSource with the GeoJson and copy over tileIndex
} else if (this.source instanceof GeojsonSource) {
data = new GeojsonSource(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,
data,
minimumZoom: options?.minimumZoom ?? this.minimumLevel,
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
});
}
/** Clones ImageryProvider, and sets paintRules to highlight picked features */
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 GeojsonSource) {
featureProp = GEOJSON_FEATURE_ID_PROP;
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;
}
}