terriajs
Version:
Geospatial data visualization platform.
320 lines (276 loc) • 8.91 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 { 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) */
.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);
}