diff --git a/modules/carto/src/api/layer-map.ts b/modules/carto/src/api/layer-map.ts index 94852b5b796..4830701de37 100644 --- a/modules/carto/src/api/layer-map.ts +++ b/modules/carto/src/api/layer-map.ts @@ -18,6 +18,7 @@ import {CPUGridLayer, HeatmapLayer, HexagonLayer} from '@deck.gl/aggregation-lay import {GeoJsonLayer} from '@deck.gl/layers'; import {H3HexagonLayer} from '@deck.gl/geo-layers'; +import ClusterTileLayer from '../layers/cluster-tile-layer'; import H3TileLayer from '../layers/h3-tile-layer'; import QuadbinTileLayer from '../layers/quadbin-tile-layer'; import RasterTileLayer from '../layers/raster-tile-layer'; @@ -46,8 +47,15 @@ const SCALE_FUNCS = { }; export type SCALE_TYPE = keyof typeof SCALE_FUNCS; -type TileLayerType = 'raster' | 'mvt' | 'tileset' | 'quadbin' | 'h3' | 'heatmapTile'; -type DocumentLayerType = 'point' | 'geojson' | 'grid' | 'heatmap' | 'hexagon' | 'hexagonId'; +type TileLayerType = + | 'clusterTile' + | 'h3' + | 'heatmapTile' + | 'mvt' + | 'quadbin' + | 'raster' + | 'tileset'; +type DocumentLayerType = 'geojson' | 'grid' | 'heatmap' | 'hexagon' | 'hexagonId' | 'point'; type LayerType = TileLayerType | DocumentLayerType; function identity(v: T): T { @@ -79,12 +87,13 @@ const AGGREGATION_FUNC = { }; const TILE_LAYER_TYPE_TO_LAYER: Record> = { - tileset: VectorTileLayer, - mvt: VectorTileLayer, - raster: RasterTileLayer, + clusterTile: ClusterTileLayer, h3: H3TileLayer, + heatmapTile: HeatmapTileLayer, + mvt: VectorTileLayer, quadbin: QuadbinTileLayer, - heatmapTile: HeatmapTileLayer + raster: RasterTileLayer, + tileset: VectorTileLayer }; const hexToRGBA = c => { diff --git a/modules/carto/src/index.ts b/modules/carto/src/index.ts index 3d1f9ab49a3..4da1dead449 100644 --- a/modules/carto/src/index.ts +++ b/modules/carto/src/index.ts @@ -1,3 +1,4 @@ +import {default as ClusterTileLayer} from './layers/cluster-tile-layer'; import {default as H3TileLayer} from './layers/h3-tile-layer'; import {default as HeatmapTileLayer} from './layers/heatmap-tile-layer'; import {default as _PointLabelLayer} from './layers/point-label-layer'; @@ -5,6 +6,7 @@ import {default as QuadbinTileLayer} from './layers/quadbin-tile-layer'; import {default as RasterTileLayer} from './layers/raster-tile-layer'; import {default as VectorTileLayer} from './layers/vector-tile-layer'; const CARTO_LAYERS = { + ClusterTileLayer, H3TileLayer, HeatmapTileLayer, _PointLabelLayer, @@ -14,6 +16,7 @@ const CARTO_LAYERS = { }; export { CARTO_LAYERS, + ClusterTileLayer, H3TileLayer, HeatmapTileLayer, _PointLabelLayer, diff --git a/modules/carto/src/layers/cluster-tile-layer.ts b/modules/carto/src/layers/cluster-tile-layer.ts new file mode 100644 index 00000000000..4d6b5e11de6 --- /dev/null +++ b/modules/carto/src/layers/cluster-tile-layer.ts @@ -0,0 +1,218 @@ +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 +} from '@deck.gl/core'; + +import { + aggregateTile, + ClusteredFeaturePropertiesT, + clustersToBinary, + computeAggregationStats, + extractAggregationProperties, + ParsedQuadbinCell, + ParsedQuadbinTile +} from './cluster-utils'; +import {DEFAULT_TILE_SIZE} from '../constants'; +import QuadbinTileset2D from './quadbin-tileset-2d'; +import {getQuadbinPolygon} from './quadbin-utils'; +import CartoSpatialTileLoader from './schema/carto-spatial-tile-loader'; +import {injectAccessToken, TilejsonPropType} from './utils'; +import type {TilejsonResult} from '../sources/types'; + +registerLoaders([CartoSpatialTileLoader]); + +const defaultProps: DefaultProps = { + data: TilejsonPropType, + clusterLevel: {type: 'number', value: 5, min: 1}, + getPosition: { + type: 'accessor', + value: ({id}) => getQuadbinPolygon(id, 0.5).slice(2, 4) as [number, number] + }, + getWeight: {type: 'accessor', value: 100}, + refinementStrategy: 'no-overlap', + tileSize: DEFAULT_TILE_SIZE +}; + +export type ClusterTileLayerPickingInfo = TileLayerPickingInfo< + ParsedQuadbinTile, + PickingInfo> +>; + +/** All properties supported by ClusterTileLayer. */ +export type ClusterTileLayerProps = + _ClusterTileLayerProps & + Omit>, 'data'>; + +/** Properties added by ClusterTileLayer. */ +type _ClusterTileLayerProps = Omit< + GeoJsonLayerProps>, + 'data' +> & { + data: null | TilejsonResult | Promise; + + /** + * The number of aggregation levels to cluster cells by. Larger values increase + * the clustering radius, 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 is used. + * + * @default cell center + */ + getPosition?: Accessor, [number, number]>; + + /** + * The weight of each cell used for clustering. + * + * @default 1 + */ + getWeight?: Accessor, number>; +}; + +class ClusterGeoJsonLayer< + FeaturePropertiesT extends {} = {}, + ExtraProps extends {} = {} +> extends TileLayer< + ParsedQuadbinTile, + ExtraProps & Required<_ClusterTileLayerProps> +> { + static layerName = 'ClusterGeoJsonLayer'; + static defaultProps = defaultProps; + state!: TileLayer['state'] & { + data: BinaryFeatureCollection; + clusterIds: bigint[]; + hoveredFeatureId: bigint | number | null; + highlightColor: number[]; + }; + + 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>[]; + if (!visibleTiles?.length) { + return null; + } + visibleTiles.sort((a, b) => b.zoom - a.zoom); + + const {zoom} = this.context.viewport; + const {clusterLevel, getPosition, getWeight} = this.props; + + const properties = extractAggregationProperties(visibleTiles[0]); + const data = [] as ClusteredFeaturePropertiesT[]; + for (const tile of visibleTiles) { + // Calculate aggregation based on viewport zoom + const overZoom = Math.round(zoom - tile.zoom); + const aggregationLevels = Math.round(clusterLevel) - overZoom; + aggregateTile(tile, aggregationLevels, properties, getPosition, getWeight); + data.push(...tile.userData![aggregationLevels]); + } + + data.sort((a, b) => Number(b.count - a.count)); + + const clusterIds = data?.map((tile: any) => tile.id); + const needsUpdate = !deepEqual(clusterIds, this.state.clusterIds, 1); + this.setState({clusterIds}); + + if (needsUpdate) { + const stats = computeAggregationStats(data, properties); + for (const d of data) { + d.stats = stats; + } + this.setState({data: clustersToBinary(data)}); + } + + 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>; + + return new GeoJsonLayer(this.getSubLayerProps(props)); + } + + getPickingInfo(params: GetPickingInfoParams): ClusterTileLayerPickingInfo { + const info = params.info as TileLayerPickingInfo>; + + 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; + } +} + +// Adapter layer around ClusterLayer that converts tileJSON into TileLayer API +export default class ClusterTileLayer< + FeaturePropertiesT = any, + ExtraProps extends {} = {} +> extends CompositeLayer>> { + static layerName = 'ClusterTileLayer'; + static defaultProps = defaultProps; + + getLoadOptions(): any { + const loadOptions = super.getLoadOptions() || {}; + const tileJSON = this.props.data as TilejsonResult; + injectAccessToken(loadOptions, tileJSON.accessToken); + loadOptions.cartoSpatialTile = {...loadOptions.cartoSpatialTile, scheme: 'quadbin'}; + return loadOptions; + } + + renderLayers(): Layer | null | LayersList { + const tileJSON = this.props.data as TilejsonResult; + if (!tileJSON) return null; + + const {tiles: data, maxresolution: maxZoom} = tileJSON; + return [ + // @ts-ignore + new ClusterGeoJsonLayer(this.props, { + id: `cluster-geojson-layer-${this.props.id}`, + data, + // TODO: Tileset2D should be generic over TileIndex type + TilesetClass: QuadbinTileset2D as any, + maxZoom, + loadOptions: this.getLoadOptions() + }) + ]; + } +} diff --git a/modules/carto/src/layers/cluster-utils.ts b/modules/carto/src/layers/cluster-utils.ts new file mode 100644 index 00000000000..d561df24585 --- /dev/null +++ b/modules/carto/src/layers/cluster-utils.ts @@ -0,0 +1,160 @@ +import {cellToParent} from 'quadbin'; +import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers'; +import {Accessor} from '@deck.gl/core'; +import {BinaryFeatureCollection} from '@loaders.gl/schema'; + +export type Aggregation = 'any' | 'average' | 'count' | 'min' | 'max' | 'sum'; +export type AggregationProperties = { + aggregation: Aggregation; + name: keyof FeaturePropertiesT; +}[]; +export type ClusteredFeaturePropertiesT = FeaturePropertiesT & { + id: bigint; + count: number; + position: [number, number]; + stats: Record; +}; +export type ParsedQuadbinCell = {id: bigint; properties: FeaturePropertiesT}; +export type ParsedQuadbinTile = ParsedQuadbinCell[]; + +export function aggregateTile( + tile: Tile2DHeader>, + aggregationLevels: number, + properties: AggregationProperties = [], + getPosition: Accessor, [number, number]>, + getWeight: Accessor, number> +): void { + if (!tile.content) return; + + // Aggregate on demand and cache result + if (!tile.userData) tile.userData = {}; + if (tile.userData[aggregationLevels]) return; + + const out: Record = {}; + for (const cell of tile.content) { + let id = cell.id; + const position = typeof getPosition === 'function' ? getPosition(cell, {} as any) : getPosition; + + // Aggregate by parent id + for (let i = 0; i < aggregationLevels - 1; i++) { + id = cellToParent(id); + } + + // Unfortunately TS doesn't support Record + // https://github.com/microsoft/TypeScript/issues/46395 + const parentId = Number(id); + if (!(parentId in out)) { + out[parentId] = {id, count: 0, position: [0, 0]}; + for (const {name, aggregation} of properties) { + if (aggregation === 'any') { + // Just pick first value for ANY + out[parentId][name] = cell.properties[name]; + } else { + out[parentId][name] = 0; + } + } + } + // Layout props + const prevTotalW = out[parentId].count; + out[parentId].count += typeof getWeight === 'function' ? getWeight(cell, {} as any) : getWeight; + + const totalW = out[parentId].count; + const W = totalW - prevTotalW; + out[parentId].position[0] = (prevTotalW * out[parentId].position[0] + W * position[0]) / totalW; + out[parentId].position[1] = (prevTotalW * out[parentId].position[1] + W * position[1]) / totalW; + + // Re-aggregate other properties so clusters can be styled + for (const {name, aggregation} of properties) { + const prevValue = out[parentId][name]; + const value = cell.properties[name] as number; + if (aggregation === 'average') { + out[parentId][name] = (prevTotalW * prevValue + W * value) / totalW; + } else if (aggregation === 'count' || aggregation === 'sum') { + out[parentId][name] = prevValue + value; + } else if (aggregation === 'max') { + out[parentId][name] = Math.max(prevValue, value); + } else if (aggregation === 'min') { + out[parentId][name] = Math.min(prevValue, value); + } + } + } + + tile.userData[aggregationLevels] = Object.values(out); +} + +export function extractAggregationProperties( + tile: Tile2DHeader> +): AggregationProperties { + const properties: AggregationProperties = []; + const validAggregations: Aggregation[] = ['any', 'average', 'count', 'min', 'max', 'sum']; + for (const name of Object.keys(tile.content![0].properties)) { + let aggregation = name.split('_').pop()!.toLowerCase() as Aggregation; + if (!validAggregations.includes(aggregation)) { + aggregation = 'any'; + } + properties.push({name: name as keyof FeaturePropertiesT, aggregation}); + } + + return properties; +} + +export function computeAggregationStats( + data: ClusteredFeaturePropertiesT[], + properties: AggregationProperties +) { + const stats = {} as Record; + for (const {name, aggregation} of properties) { + stats[name] = {min: Infinity, max: -Infinity}; + if (aggregation !== 'any') { + for (const d of data) { + stats[name].min = Math.min(stats[name].min, d[name] as number); + stats[name].max = Math.max(stats[name].max, d[name] as number); + } + } + } + + return stats; +} + +const EMPTY_UINT16ARRAY = new Uint16Array(); +const EMPTY_BINARY_PROPS = { + positions: {value: new Float32Array(), size: 2}, + properties: [], + numericProps: {}, + featureIds: {value: EMPTY_UINT16ARRAY, size: 1}, + globalFeatureIds: {value: EMPTY_UINT16ARRAY, size: 1} +}; + +export function clustersToBinary( + data: ClusteredFeaturePropertiesT[] +): BinaryFeatureCollection { + const positions = new Float32Array(data.length * 2); + const featureIds = new Uint16Array(data.length); + for (let i = 0; i < data.length; i++) { + positions.set(data[i].position, 2 * i); + featureIds[i] = i; + } + + return { + shape: 'binary-feature-collection', + points: { + type: 'Point', + positions: {value: positions, size: 2}, + properties: data, + numericProps: {}, + featureIds: {value: featureIds, size: 1}, + globalFeatureIds: {value: featureIds, size: 1} + }, + lines: { + type: 'LineString', + pathIndices: {value: EMPTY_UINT16ARRAY, size: 1}, + ...EMPTY_BINARY_PROPS + }, + polygons: { + type: 'Polygon', + polygonIndices: {value: EMPTY_UINT16ARRAY, size: 1}, + primitivePolygonIndices: {value: EMPTY_UINT16ARRAY, size: 1}, + ...EMPTY_BINARY_PROPS + } + }; +} diff --git a/modules/carto/src/style/color-bins-style.ts b/modules/carto/src/style/color-bins-style.ts index ee2924eb0a5..e64c1ea5258 100644 --- a/modules/carto/src/style/color-bins-style.ts +++ b/modules/carto/src/style/color-bins-style.ts @@ -1,7 +1,7 @@ import {scaleThreshold} from 'd3-scale'; -import {AccessorFunction} from '@deck.gl/core'; +import {AccessorFunction, Color} from '@deck.gl/core'; import {Feature} from 'geojson'; -import getPalette, {Color, DEFAULT_PALETTE, NULL_COLOR} from './palette'; +import getPalette, {DEFAULT_PALETTE, NULL_COLOR} from './palette'; import {assert} from '../utils'; import {AttributeSelector, getAttrValue} from './utils'; diff --git a/modules/carto/src/style/color-categories-style.ts b/modules/carto/src/style/color-categories-style.ts index bc0e991ca4d..9839117b0f3 100644 --- a/modules/carto/src/style/color-categories-style.ts +++ b/modules/carto/src/style/color-categories-style.ts @@ -1,6 +1,6 @@ -import {AccessorFunction} from '@deck.gl/core'; +import {AccessorFunction, Color} from '@deck.gl/core'; import {Feature} from 'geojson'; -import getPalette, {Color, DEFAULT_PALETTE, NULL_COLOR, OTHERS_COLOR} from './palette'; +import getPalette, {DEFAULT_PALETTE, NULL_COLOR, OTHERS_COLOR} from './palette'; import {assert} from '../utils'; import {AttributeSelector, getAttrValue} from './utils'; diff --git a/modules/carto/src/style/color-continuous-style.ts b/modules/carto/src/style/color-continuous-style.ts index ac9958f0ac5..6f016a82edb 100644 --- a/modules/carto/src/style/color-continuous-style.ts +++ b/modules/carto/src/style/color-continuous-style.ts @@ -1,7 +1,7 @@ -import {AccessorFunction} from '@deck.gl/core'; +import {AccessorFunction, Color} from '@deck.gl/core'; import {scaleLinear} from 'd3-scale'; import {Feature} from 'geojson'; -import getPalette, {Color, DEFAULT_PALETTE, NULL_COLOR} from './palette'; +import getPalette, {DEFAULT_PALETTE, NULL_COLOR} from './palette'; import {assert} from '../utils'; import {AttributeSelector, getAttrValue} from './utils'; diff --git a/modules/carto/src/style/palette.ts b/modules/carto/src/style/palette.ts index ec2dac60f23..ac32e34ba35 100644 --- a/modules/carto/src/style/palette.ts +++ b/modules/carto/src/style/palette.ts @@ -1,7 +1,6 @@ import * as cartoColors from 'cartocolor'; import {assert} from '../utils'; - -export type Color = [r: number, b: number, g: number, a?: number]; +import {Color} from '@deck.gl/core'; export const DEFAULT_PALETTE = 'PurpOr'; export const NULL_COLOR: Color = [204, 204, 204];