UNPKG

@deck.gl/carto

Version:

CARTO official integration with Deck.gl. Build geospatial applications using CARTO and Deck.gl.

321 lines (279 loc) 10.5 kB
// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors /* eslint-disable no-shadow */ import {GeoJsonLayer, GeoJsonLayerProps} from '@deck.gl/layers'; import { TileLayer, _Tile2DHeader as Tile2DHeader, TileLayerProps, TileLayerPickingInfo } from '@deck.gl/geo-layers'; import {registerLoaders} from '@loaders.gl/core'; import {binaryToGeojson} from '@loaders.gl/gis'; import {BinaryFeatureCollection} from '@loaders.gl/schema'; import type {Feature, Geometry} from 'geojson'; import { Accessor, DefaultProps, CompositeLayer, _deepEqual as deepEqual, GetPickingInfoParams, Layer, LayersList, PickingInfo, WebMercatorViewport } from '@deck.gl/core'; import { aggregateTile, ClusteredFeaturePropertiesT, clustersToBinary, computeAggregationStats, extractAggregationProperties, ParsedQuadbinCell, ParsedQuadbinTile, ParsedH3Cell, ParsedH3Tile } from './cluster-utils'; import {DEFAULT_TILE_SIZE} from '../constants'; import QuadbinTileset2D from './quadbin-tileset-2d'; import H3Tileset2D, {getHexagonResolution} from './h3-tileset-2d'; import {getQuadbinPolygon} from './quadbin-utils'; import {getResolution, cellToLatLng} from 'h3-js'; import CartoSpatialTileLoader from './schema/carto-spatial-tile-loader'; import {TilejsonPropType, mergeLoadOptions} from './utils'; import type {TilejsonResult} from '@carto/api-client'; registerLoaders([CartoSpatialTileLoader]); function getScheme(tilesetClass: typeof H3Tileset2D | typeof QuadbinTileset2D): 'h3' | 'quadbin' { if (tilesetClass === H3Tileset2D) return 'h3'; if (tilesetClass === QuadbinTileset2D) return 'quadbin'; throw new Error('Invalid tileset class'); } const defaultProps: DefaultProps<ClusterTileLayerProps> = { data: TilejsonPropType, clusterLevel: {type: 'number', value: 5, min: 1}, getPosition: { type: 'accessor', value: ({id}) => { // Determine scheme based on ID type: H3 uses string IDs, Quadbin uses bigint IDs if (typeof id === 'string') { const [lat, lng] = cellToLatLng(id); return [lng, lat]; } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return getQuadbinPolygon(id as bigint, 0.5).slice(2, 4) as [number, number]; } }, getWeight: {type: 'accessor', value: 1}, refinementStrategy: 'no-overlap', tileSize: DEFAULT_TILE_SIZE }; export type ClusterTileLayerPickingInfo<FeaturePropertiesT = {}> = TileLayerPickingInfo< ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>, PickingInfo<Feature<Geometry, FeaturePropertiesT>> >; /** All properties supported by ClusterTileLayer. */ export type ClusterTileLayerProps<FeaturePropertiesT = unknown> = _ClusterTileLayerProps<FeaturePropertiesT> & Omit< TileLayerProps<ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>>, 'data' >; /** Properties added by ClusterTileLayer. */ type _ClusterTileLayerProps<FeaturePropertiesT> = Omit< GeoJsonLayerProps<ClusteredFeaturePropertiesT<FeaturePropertiesT>>, 'data' > & { data: null | TilejsonResult | Promise<TilejsonResult>; /** * The number of aggregation levels to cluster cells by. Larger values increase * the clustering radius, with an increment of `clusterLevel` doubling the radius. * * @default 5 */ clusterLevel?: number; /** * The (average) position of points in a cell used for clustering. * If not supplied the center of the quadbin cell or H3 cell is used. * * @default cell center */ getPosition?: Accessor< ParsedQuadbinCell<FeaturePropertiesT> | ParsedH3Cell<FeaturePropertiesT>, [number, number] >; /** * The weight of each cell used for clustering. * * @default 1 */ getWeight?: Accessor< ParsedQuadbinCell<FeaturePropertiesT> | ParsedH3Cell<FeaturePropertiesT>, number >; }; class ClusterGeoJsonLayer< FeaturePropertiesT extends {} = {}, ExtraProps extends {} = {} > extends TileLayer< ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>, ExtraProps & Required<_ClusterTileLayerProps<FeaturePropertiesT>> > { static layerName = 'ClusterGeoJsonLayer'; static defaultProps = defaultProps; state!: TileLayer<FeaturePropertiesT>['state'] & { data: BinaryFeatureCollection; clusterIds: (bigint | string)[]; hoveredFeatureId: bigint | string | number | null; highlightColor: number[]; aggregationCache: WeakMap<any, Map<number, ClusteredFeaturePropertiesT<FeaturePropertiesT>[]>>; scheme: string | null; }; initializeState() { super.initializeState(); this.state.aggregationCache = new WeakMap(); this.state.scheme = getScheme(this.props.TilesetClass as any); } updateState(opts) { const {props} = opts; const scheme = getScheme(props.TilesetClass); if (this.state.scheme !== scheme) { // Clear caches when scheme changes this.setState({scheme, tileset: null}); this.state.aggregationCache = new WeakMap(); } super.updateState(opts); } // eslint-disable-next-line max-statements renderLayers(): Layer | null | LayersList { const visibleTiles = this.state.tileset?.tiles.filter((tile: Tile2DHeader) => { return tile.isLoaded && tile.content && this.state.tileset!.isTileVisible(tile); }) as Tile2DHeader<ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>>[]; if (!visibleTiles?.length || !this.state.tileset) { return null; } visibleTiles.sort((a, b) => b.zoom - a.zoom); const {getPosition, getWeight} = this.props; const {aggregationCache, scheme} = this.state; const isH3 = scheme === 'h3'; const properties = extractAggregationProperties(visibleTiles[0]); const data = [] as ClusteredFeaturePropertiesT<FeaturePropertiesT>[]; let needsUpdate = false; const aggregationLevels = this._getAggregationLevels(visibleTiles); for (const tile of visibleTiles) { // Calculate aggregation based on viewport zoom let tileAggregationCache = aggregationCache.get(tile.content); if (!tileAggregationCache) { tileAggregationCache = new Map(); aggregationCache.set(tile.content, tileAggregationCache); } const didAggregate = aggregateTile( tile, tileAggregationCache, aggregationLevels, properties, getPosition, getWeight, isH3 ? 'h3' : 'quadbin' ); needsUpdate ||= didAggregate; data.push(...tileAggregationCache.get(aggregationLevels)!); } data.sort((a, b) => Number(b.count - a.count)); const clusterIds = data?.map((tile: any) => tile.id); needsUpdate ||= !deepEqual(clusterIds, this.state.clusterIds, 1); this.setState({clusterIds}); if (needsUpdate) { const stats = computeAggregationStats(data, properties); const binaryData = clustersToBinary(data); binaryData.points.attributes = {stats}; this.setState({data: binaryData}); } const props = { ...this.props, id: 'clusters', data: this.state.data, dataComparator: (data?: BinaryFeatureCollection, oldData?: BinaryFeatureCollection) => { const newIds = data?.points?.properties?.map((tile: any) => tile.id); const oldIds = oldData?.points?.properties?.map((tile: any) => tile.id); return deepEqual(newIds, oldIds, 1); } } as GeoJsonLayerProps<ClusteredFeaturePropertiesT<FeaturePropertiesT>>; return new GeoJsonLayer(this.getSubLayerProps(props)); } getPickingInfo(params: GetPickingInfoParams): ClusterTileLayerPickingInfo<FeaturePropertiesT> { const info = params.info as TileLayerPickingInfo< ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT> >; if (info.index !== -1) { const {data} = params.sourceLayer!.props; info.object = binaryToGeojson(data as BinaryFeatureCollection, { globalFeatureId: info.index }) as Feature; } return info; } protected _updateAutoHighlight(info: PickingInfo): void { for (const layer of this.getSubLayers()) { layer.updateAutoHighlight(info); } } filterSubLayer() { return true; } private _getAggregationLevels(visibleTiles: Tile2DHeader[]): number { const isH3 = this.state.scheme === 'h3'; const firstTile = visibleTiles[0]; // Resolution of data present in tiles let tileResolution; // Resolution of tiles that should be (eventually) visible in the viewport let viewportResolution; if (isH3) { tileResolution = getResolution(firstTile.id); viewportResolution = getHexagonResolution( this.context.viewport as WebMercatorViewport, (this.state.tileset as any).opts.tileSize ); } else { tileResolution = firstTile.zoom; viewportResolution = this.context.viewport.zoom; } const resolutionDiff = Math.round(viewportResolution - tileResolution); const aggregationLevels = Math.round(this.props.clusterLevel) - resolutionDiff; return aggregationLevels; } } // Adapter layer around ClusterLayer that converts tileJSON into TileLayer API export default class ClusterTileLayer< FeaturePropertiesT = any, ExtraProps extends {} = {} > extends CompositeLayer<ExtraProps & Required<_ClusterTileLayerProps<FeaturePropertiesT>>> { static layerName = 'ClusterTileLayer'; static defaultProps = defaultProps; getLoadOptions(): any { const tileJSON = this.props.data as TilejsonResult; const scheme = tileJSON && 'scheme' in tileJSON ? tileJSON.scheme : 'quadbin'; return mergeLoadOptions(super.getLoadOptions(), { fetch: {headers: {Authorization: `Bearer ${tileJSON.accessToken}`}}, cartoSpatialTile: {scheme} }); } renderLayers(): Layer | null | LayersList { const tileJSON = this.props.data as TilejsonResult; if (!tileJSON) return null; const {tiles: data, maxresolution: maxZoom} = tileJSON; const isH3 = tileJSON && 'scheme' in tileJSON && tileJSON.scheme === 'h3'; const TilesetClass = isH3 ? H3Tileset2D : QuadbinTileset2D; return [ // @ts-ignore new ClusterGeoJsonLayer(this.props, { id: `cluster-geojson-layer-${this.props.id}`, data, // TODO: Tileset2D should be generic over TileIndex type TilesetClass: TilesetClass as any, maxZoom, loadOptions: this.getLoadOptions() }) ]; } }