Skip to content

Commit 0af27a1

Browse files
authored
CARTO: ClusterTileLayer (#8957)
1 parent 92363b3 commit 0af27a1

8 files changed

Lines changed: 403 additions & 14 deletions

File tree

modules/carto/src/api/layer-map.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {CPUGridLayer, HeatmapLayer, HexagonLayer} from '@deck.gl/aggregation-lay
1818
import {GeoJsonLayer} from '@deck.gl/layers';
1919
import {H3HexagonLayer} from '@deck.gl/geo-layers';
2020

21+
import ClusterTileLayer from '../layers/cluster-tile-layer';
2122
import H3TileLayer from '../layers/h3-tile-layer';
2223
import QuadbinTileLayer from '../layers/quadbin-tile-layer';
2324
import RasterTileLayer from '../layers/raster-tile-layer';
@@ -46,8 +47,15 @@ const SCALE_FUNCS = {
4647
};
4748
export type SCALE_TYPE = keyof typeof SCALE_FUNCS;
4849

49-
type TileLayerType = 'raster' | 'mvt' | 'tileset' | 'quadbin' | 'h3' | 'heatmapTile';
50-
type DocumentLayerType = 'point' | 'geojson' | 'grid' | 'heatmap' | 'hexagon' | 'hexagonId';
50+
type TileLayerType =
51+
| 'clusterTile'
52+
| 'h3'
53+
| 'heatmapTile'
54+
| 'mvt'
55+
| 'quadbin'
56+
| 'raster'
57+
| 'tileset';
58+
type DocumentLayerType = 'geojson' | 'grid' | 'heatmap' | 'hexagon' | 'hexagonId' | 'point';
5159
type LayerType = TileLayerType | DocumentLayerType;
5260

5361
function identity<T>(v: T): T {
@@ -79,12 +87,13 @@ const AGGREGATION_FUNC = {
7987
};
8088

8189
const TILE_LAYER_TYPE_TO_LAYER: Record<TileLayerType, ConstructorOf<Layer>> = {
82-
tileset: VectorTileLayer,
83-
mvt: VectorTileLayer,
84-
raster: RasterTileLayer,
90+
clusterTile: ClusterTileLayer,
8591
h3: H3TileLayer,
92+
heatmapTile: HeatmapTileLayer,
93+
mvt: VectorTileLayer,
8694
quadbin: QuadbinTileLayer,
87-
heatmapTile: HeatmapTileLayer
95+
raster: RasterTileLayer,
96+
tileset: VectorTileLayer
8897
};
8998

9099
const hexToRGBA = c => {

modules/carto/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import {default as ClusterTileLayer} from './layers/cluster-tile-layer';
12
import {default as H3TileLayer} from './layers/h3-tile-layer';
23
import {default as HeatmapTileLayer} from './layers/heatmap-tile-layer';
34
import {default as _PointLabelLayer} from './layers/point-label-layer';
45
import {default as QuadbinTileLayer} from './layers/quadbin-tile-layer';
56
import {default as RasterTileLayer} from './layers/raster-tile-layer';
67
import {default as VectorTileLayer} from './layers/vector-tile-layer';
78
const CARTO_LAYERS = {
9+
ClusterTileLayer,
810
H3TileLayer,
911
HeatmapTileLayer,
1012
_PointLabelLayer,
@@ -14,6 +16,7 @@ const CARTO_LAYERS = {
1416
};
1517
export {
1618
CARTO_LAYERS,
19+
ClusterTileLayer,
1720
H3TileLayer,
1821
HeatmapTileLayer,
1922
_PointLabelLayer,
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import {GeoJsonLayer, GeoJsonLayerProps} from '@deck.gl/layers';
2+
import {
3+
TileLayer,
4+
_Tile2DHeader as Tile2DHeader,
5+
TileLayerProps,
6+
TileLayerPickingInfo
7+
} from '@deck.gl/geo-layers';
8+
import {registerLoaders} from '@loaders.gl/core';
9+
import {binaryToGeojson} from '@loaders.gl/gis';
10+
import {BinaryFeatureCollection} from '@loaders.gl/schema';
11+
import type {Feature, Geometry} from 'geojson';
12+
13+
import {
14+
Accessor,
15+
DefaultProps,
16+
CompositeLayer,
17+
_deepEqual as deepEqual,
18+
GetPickingInfoParams,
19+
Layer,
20+
LayersList,
21+
PickingInfo
22+
} from '@deck.gl/core';
23+
24+
import {
25+
aggregateTile,
26+
ClusteredFeaturePropertiesT,
27+
clustersToBinary,
28+
computeAggregationStats,
29+
extractAggregationProperties,
30+
ParsedQuadbinCell,
31+
ParsedQuadbinTile
32+
} from './cluster-utils';
33+
import {DEFAULT_TILE_SIZE} from '../constants';
34+
import QuadbinTileset2D from './quadbin-tileset-2d';
35+
import {getQuadbinPolygon} from './quadbin-utils';
36+
import CartoSpatialTileLoader from './schema/carto-spatial-tile-loader';
37+
import {injectAccessToken, TilejsonPropType} from './utils';
38+
import type {TilejsonResult} from '../sources/types';
39+
40+
registerLoaders([CartoSpatialTileLoader]);
41+
42+
const defaultProps: DefaultProps<ClusterTileLayerProps> = {
43+
data: TilejsonPropType,
44+
clusterLevel: {type: 'number', value: 5, min: 1},
45+
getPosition: {
46+
type: 'accessor',
47+
value: ({id}) => getQuadbinPolygon(id, 0.5).slice(2, 4) as [number, number]
48+
},
49+
getWeight: {type: 'accessor', value: 100},
50+
refinementStrategy: 'no-overlap',
51+
tileSize: DEFAULT_TILE_SIZE
52+
};
53+
54+
export type ClusterTileLayerPickingInfo<FeaturePropertiesT = {}> = TileLayerPickingInfo<
55+
ParsedQuadbinTile<FeaturePropertiesT>,
56+
PickingInfo<Feature<Geometry, FeaturePropertiesT>>
57+
>;
58+
59+
/** All properties supported by ClusterTileLayer. */
60+
export type ClusterTileLayerProps<FeaturePropertiesT = unknown> =
61+
_ClusterTileLayerProps<FeaturePropertiesT> &
62+
Omit<TileLayerProps<ParsedQuadbinTile<FeaturePropertiesT>>, 'data'>;
63+
64+
/** Properties added by ClusterTileLayer. */
65+
type _ClusterTileLayerProps<FeaturePropertiesT> = Omit<
66+
GeoJsonLayerProps<ClusteredFeaturePropertiesT<FeaturePropertiesT>>,
67+
'data'
68+
> & {
69+
data: null | TilejsonResult | Promise<TilejsonResult>;
70+
71+
/**
72+
* The number of aggregation levels to cluster cells by. Larger values increase
73+
* the clustering radius, an increment of `clusterLevel` doubling the radius.
74+
*
75+
* @default 5
76+
*/
77+
clusterLevel?: number;
78+
79+
/**
80+
* The (average) position of points in a cell used for clustering.
81+
* If not supplied the center of the quadbin cell is used.
82+
*
83+
* @default cell center
84+
*/
85+
getPosition?: Accessor<ParsedQuadbinCell<FeaturePropertiesT>, [number, number]>;
86+
87+
/**
88+
* The weight of each cell used for clustering.
89+
*
90+
* @default 1
91+
*/
92+
getWeight?: Accessor<ParsedQuadbinCell<FeaturePropertiesT>, number>;
93+
};
94+
95+
class ClusterGeoJsonLayer<
96+
FeaturePropertiesT extends {} = {},
97+
ExtraProps extends {} = {}
98+
> extends TileLayer<
99+
ParsedQuadbinTile<FeaturePropertiesT>,
100+
ExtraProps & Required<_ClusterTileLayerProps<FeaturePropertiesT>>
101+
> {
102+
static layerName = 'ClusterGeoJsonLayer';
103+
static defaultProps = defaultProps;
104+
state!: TileLayer<FeaturePropertiesT>['state'] & {
105+
data: BinaryFeatureCollection;
106+
clusterIds: bigint[];
107+
hoveredFeatureId: bigint | number | null;
108+
highlightColor: number[];
109+
};
110+
111+
renderLayers(): Layer | null | LayersList {
112+
const visibleTiles = this.state.tileset?.tiles.filter((tile: Tile2DHeader) => {
113+
return tile.isLoaded && tile.content && this.state.tileset!.isTileVisible(tile);
114+
}) as Tile2DHeader<ParsedQuadbinTile<FeaturePropertiesT>>[];
115+
if (!visibleTiles?.length) {
116+
return null;
117+
}
118+
visibleTiles.sort((a, b) => b.zoom - a.zoom);
119+
120+
const {zoom} = this.context.viewport;
121+
const {clusterLevel, getPosition, getWeight} = this.props;
122+
123+
const properties = extractAggregationProperties(visibleTiles[0]);
124+
const data = [] as ClusteredFeaturePropertiesT<FeaturePropertiesT>[];
125+
for (const tile of visibleTiles) {
126+
// Calculate aggregation based on viewport zoom
127+
const overZoom = Math.round(zoom - tile.zoom);
128+
const aggregationLevels = Math.round(clusterLevel) - overZoom;
129+
aggregateTile(tile, aggregationLevels, properties, getPosition, getWeight);
130+
data.push(...tile.userData![aggregationLevels]);
131+
}
132+
133+
data.sort((a, b) => Number(b.count - a.count));
134+
135+
const clusterIds = data?.map((tile: any) => tile.id);
136+
const needsUpdate = !deepEqual(clusterIds, this.state.clusterIds, 1);
137+
this.setState({clusterIds});
138+
139+
if (needsUpdate) {
140+
const stats = computeAggregationStats(data, properties);
141+
for (const d of data) {
142+
d.stats = stats;
143+
}
144+
this.setState({data: clustersToBinary(data)});
145+
}
146+
147+
const props = {
148+
...this.props,
149+
id: 'clusters',
150+
data: this.state.data,
151+
dataComparator: (data?: BinaryFeatureCollection, oldData?: BinaryFeatureCollection) => {
152+
const newIds = data?.points?.properties?.map((tile: any) => tile.id);
153+
const oldIds = oldData?.points?.properties?.map((tile: any) => tile.id);
154+
return deepEqual(newIds, oldIds, 1);
155+
}
156+
} as GeoJsonLayerProps<ClusteredFeaturePropertiesT<FeaturePropertiesT>>;
157+
158+
return new GeoJsonLayer(this.getSubLayerProps(props));
159+
}
160+
161+
getPickingInfo(params: GetPickingInfoParams): ClusterTileLayerPickingInfo<FeaturePropertiesT> {
162+
const info = params.info as TileLayerPickingInfo<ParsedQuadbinTile<FeaturePropertiesT>>;
163+
164+
if (info.index !== -1) {
165+
const {data} = params.sourceLayer!.props;
166+
info.object = binaryToGeojson(data as BinaryFeatureCollection, {
167+
globalFeatureId: info.index
168+
}) as Feature;
169+
}
170+
171+
return info;
172+
}
173+
174+
protected _updateAutoHighlight(info: PickingInfo): void {
175+
for (const layer of this.getSubLayers()) {
176+
layer.updateAutoHighlight(info);
177+
}
178+
}
179+
180+
filterSubLayer() {
181+
return true;
182+
}
183+
}
184+
185+
// Adapter layer around ClusterLayer that converts tileJSON into TileLayer API
186+
export default class ClusterTileLayer<
187+
FeaturePropertiesT = any,
188+
ExtraProps extends {} = {}
189+
> extends CompositeLayer<ExtraProps & Required<_ClusterTileLayerProps<FeaturePropertiesT>>> {
190+
static layerName = 'ClusterTileLayer';
191+
static defaultProps = defaultProps;
192+
193+
getLoadOptions(): any {
194+
const loadOptions = super.getLoadOptions() || {};
195+
const tileJSON = this.props.data as TilejsonResult;
196+
injectAccessToken(loadOptions, tileJSON.accessToken);
197+
loadOptions.cartoSpatialTile = {...loadOptions.cartoSpatialTile, scheme: 'quadbin'};
198+
return loadOptions;
199+
}
200+
201+
renderLayers(): Layer | null | LayersList {
202+
const tileJSON = this.props.data as TilejsonResult;
203+
if (!tileJSON) return null;
204+
205+
const {tiles: data, maxresolution: maxZoom} = tileJSON;
206+
return [
207+
// @ts-ignore
208+
new ClusterGeoJsonLayer(this.props, {
209+
id: `cluster-geojson-layer-${this.props.id}`,
210+
data,
211+
// TODO: Tileset2D should be generic over TileIndex type
212+
TilesetClass: QuadbinTileset2D as any,
213+
maxZoom,
214+
loadOptions: this.getLoadOptions()
215+
})
216+
];
217+
}
218+
}

0 commit comments

Comments
 (0)