Skip to content

Commit 75dd4f3

Browse files
authored
CARTO: Support h3 in ClusterTileLayer (#9755)
* First attempt at cluster h3 * try to fix dynamic tile app * heatmap test app * test app clusters * Correctly read in stats * determine scheme in update state * Better scheme change detection * h3 zoom level to res mapping * Better palette handling * Palette utils * _getAggregationLevels * Merge branch 'master' into felix/h3-cluster-tile-layer * remove datasetss * cellToLatLng * Revert test app changes * Lint * lint again * token * lint
1 parent 84407de commit 75dd4f3

File tree

5 files changed

+250
-94
lines changed

5 files changed

+250
-94
lines changed

modules/carto/src/layers/cluster-tile-layer.ts

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import {
2424
GetPickingInfoParams,
2525
Layer,
2626
LayersList,
27-
PickingInfo
27+
PickingInfo,
28+
WebMercatorViewport
2829
} from '@deck.gl/core';
2930

3031
import {
@@ -34,38 +35,59 @@ import {
3435
computeAggregationStats,
3536
extractAggregationProperties,
3637
ParsedQuadbinCell,
37-
ParsedQuadbinTile
38+
ParsedQuadbinTile,
39+
ParsedH3Cell,
40+
ParsedH3Tile
3841
} from './cluster-utils';
3942
import {DEFAULT_TILE_SIZE} from '../constants';
4043
import QuadbinTileset2D from './quadbin-tileset-2d';
44+
import H3Tileset2D, {getHexagonResolution} from './h3-tileset-2d';
4145
import {getQuadbinPolygon} from './quadbin-utils';
46+
import {getResolution, cellToLatLng} from 'h3-js';
4247
import CartoSpatialTileLoader from './schema/carto-spatial-tile-loader';
4348
import {TilejsonPropType, mergeLoadOptions} from './utils';
4449
import type {TilejsonResult} from '@carto/api-client';
4550

4651
registerLoaders([CartoSpatialTileLoader]);
4752

53+
function getScheme(tilesetClass: typeof H3Tileset2D | typeof QuadbinTileset2D): 'h3' | 'quadbin' {
54+
if (tilesetClass === H3Tileset2D) return 'h3';
55+
if (tilesetClass === QuadbinTileset2D) return 'quadbin';
56+
throw new Error('Invalid tileset class');
57+
}
58+
4859
const defaultProps: DefaultProps<ClusterTileLayerProps> = {
4960
data: TilejsonPropType,
5061
clusterLevel: {type: 'number', value: 5, min: 1},
5162
getPosition: {
5263
type: 'accessor',
53-
value: ({id}) => getQuadbinPolygon(id, 0.5).slice(2, 4) as [number, number]
64+
value: ({id}) => {
65+
// Determine scheme based on ID type: H3 uses string IDs, Quadbin uses bigint IDs
66+
if (typeof id === 'string') {
67+
const [lat, lng] = cellToLatLng(id);
68+
return [lng, lat];
69+
}
70+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
71+
return getQuadbinPolygon(id as bigint, 0.5).slice(2, 4) as [number, number];
72+
}
5473
},
5574
getWeight: {type: 'accessor', value: 1},
5675
refinementStrategy: 'no-overlap',
5776
tileSize: DEFAULT_TILE_SIZE
5877
};
5978

6079
export type ClusterTileLayerPickingInfo<FeaturePropertiesT = {}> = TileLayerPickingInfo<
61-
ParsedQuadbinTile<FeaturePropertiesT>,
80+
ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>,
6281
PickingInfo<Feature<Geometry, FeaturePropertiesT>>
6382
>;
6483

6584
/** All properties supported by ClusterTileLayer. */
6685
export type ClusterTileLayerProps<FeaturePropertiesT = unknown> =
6786
_ClusterTileLayerProps<FeaturePropertiesT> &
68-
Omit<TileLayerProps<ParsedQuadbinTile<FeaturePropertiesT>>, 'data'>;
87+
Omit<
88+
TileLayerProps<ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>>,
89+
'data'
90+
>;
6991

7092
/** Properties added by ClusterTileLayer. */
7193
type _ClusterTileLayerProps<FeaturePropertiesT> = Omit<
@@ -84,63 +106,84 @@ type _ClusterTileLayerProps<FeaturePropertiesT> = Omit<
84106

85107
/**
86108
* The (average) position of points in a cell used for clustering.
87-
* If not supplied the center of the quadbin cell is used.
109+
* If not supplied the center of the quadbin cell or H3 cell is used.
88110
*
89111
* @default cell center
90112
*/
91-
getPosition?: Accessor<ParsedQuadbinCell<FeaturePropertiesT>, [number, number]>;
113+
getPosition?: Accessor<
114+
ParsedQuadbinCell<FeaturePropertiesT> | ParsedH3Cell<FeaturePropertiesT>,
115+
[number, number]
116+
>;
92117

93118
/**
94119
* The weight of each cell used for clustering.
95120
*
96121
* @default 1
97122
*/
98-
getWeight?: Accessor<ParsedQuadbinCell<FeaturePropertiesT>, number>;
123+
getWeight?: Accessor<
124+
ParsedQuadbinCell<FeaturePropertiesT> | ParsedH3Cell<FeaturePropertiesT>,
125+
number
126+
>;
99127
};
100128

101129
class ClusterGeoJsonLayer<
102130
FeaturePropertiesT extends {} = {},
103131
ExtraProps extends {} = {}
104132
> extends TileLayer<
105-
ParsedQuadbinTile<FeaturePropertiesT>,
133+
ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>,
106134
ExtraProps & Required<_ClusterTileLayerProps<FeaturePropertiesT>>
107135
> {
108136
static layerName = 'ClusterGeoJsonLayer';
109137
static defaultProps = defaultProps;
110138
state!: TileLayer<FeaturePropertiesT>['state'] & {
111139
data: BinaryFeatureCollection;
112-
clusterIds: bigint[];
113-
hoveredFeatureId: bigint | number | null;
140+
clusterIds: (bigint | string)[];
141+
hoveredFeatureId: bigint | string | number | null;
114142
highlightColor: number[];
115143
aggregationCache: WeakMap<any, Map<number, ClusteredFeaturePropertiesT<FeaturePropertiesT>[]>>;
144+
scheme: string | null;
116145
};
117146

118147
initializeState() {
119148
super.initializeState();
120149
this.state.aggregationCache = new WeakMap();
150+
this.state.scheme = getScheme(this.props.TilesetClass as any);
151+
}
152+
153+
updateState(opts) {
154+
const {props} = opts;
155+
const scheme = getScheme(props.TilesetClass);
156+
if (this.state.scheme !== scheme) {
157+
// Clear caches when scheme changes
158+
this.setState({scheme, tileset: null});
159+
this.state.aggregationCache = new WeakMap();
160+
}
161+
162+
super.updateState(opts);
121163
}
122164

123165
// eslint-disable-next-line max-statements
124166
renderLayers(): Layer | null | LayersList {
125167
const visibleTiles = this.state.tileset?.tiles.filter((tile: Tile2DHeader) => {
126168
return tile.isLoaded && tile.content && this.state.tileset!.isTileVisible(tile);
127-
}) as Tile2DHeader<ParsedQuadbinTile<FeaturePropertiesT>>[];
128-
if (!visibleTiles?.length) {
169+
}) as Tile2DHeader<ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>>[];
170+
if (!visibleTiles?.length || !this.state.tileset) {
129171
return null;
130172
}
131173
visibleTiles.sort((a, b) => b.zoom - a.zoom);
174+
const {getPosition, getWeight} = this.props;
175+
const {aggregationCache, scheme} = this.state;
132176

133-
const {zoom} = this.context.viewport;
134-
const {clusterLevel, getPosition, getWeight} = this.props;
135-
const {aggregationCache} = this.state;
177+
const isH3 = scheme === 'h3';
136178

137179
const properties = extractAggregationProperties(visibleTiles[0]);
138180
const data = [] as ClusteredFeaturePropertiesT<FeaturePropertiesT>[];
139181
let needsUpdate = false;
182+
183+
const aggregationLevels = this._getAggregationLevels(visibleTiles);
184+
140185
for (const tile of visibleTiles) {
141186
// Calculate aggregation based on viewport zoom
142-
const overZoom = Math.round(zoom - tile.zoom);
143-
const aggregationLevels = Math.round(clusterLevel) - overZoom;
144187
let tileAggregationCache = aggregationCache.get(tile.content);
145188
if (!tileAggregationCache) {
146189
tileAggregationCache = new Map();
@@ -152,7 +195,8 @@ class ClusterGeoJsonLayer<
152195
aggregationLevels,
153196
properties,
154197
getPosition,
155-
getWeight
198+
getWeight,
199+
isH3 ? 'h3' : 'quadbin'
156200
);
157201
needsUpdate ||= didAggregate;
158202
data.push(...tileAggregationCache.get(aggregationLevels)!);
@@ -186,7 +230,9 @@ class ClusterGeoJsonLayer<
186230
}
187231

188232
getPickingInfo(params: GetPickingInfoParams): ClusterTileLayerPickingInfo<FeaturePropertiesT> {
189-
const info = params.info as TileLayerPickingInfo<ParsedQuadbinTile<FeaturePropertiesT>>;
233+
const info = params.info as TileLayerPickingInfo<
234+
ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>
235+
>;
190236

191237
if (info.index !== -1) {
192238
const {data} = params.sourceLayer!.props;
@@ -207,6 +253,31 @@ class ClusterGeoJsonLayer<
207253
filterSubLayer() {
208254
return true;
209255
}
256+
257+
private _getAggregationLevels(visibleTiles: Tile2DHeader[]): number {
258+
const isH3 = this.state.scheme === 'h3';
259+
const firstTile = visibleTiles[0];
260+
261+
// Resolution of data present in tiles
262+
let tileResolution;
263+
264+
// Resolution of tiles that should be (eventually) visible in the viewport
265+
let viewportResolution;
266+
if (isH3) {
267+
tileResolution = getResolution(firstTile.id);
268+
viewportResolution = getHexagonResolution(
269+
this.context.viewport as WebMercatorViewport,
270+
(this.state.tileset as any).opts.tileSize
271+
);
272+
} else {
273+
tileResolution = firstTile.zoom;
274+
viewportResolution = this.context.viewport.zoom;
275+
}
276+
277+
const resolutionDiff = Math.round(viewportResolution - tileResolution);
278+
const aggregationLevels = Math.round(this.props.clusterLevel) - resolutionDiff;
279+
return aggregationLevels;
280+
}
210281
}
211282

212283
// Adapter layer around ClusterLayer that converts tileJSON into TileLayer API
@@ -219,9 +290,10 @@ export default class ClusterTileLayer<
219290

220291
getLoadOptions(): any {
221292
const tileJSON = this.props.data as TilejsonResult;
293+
const scheme = tileJSON && 'scheme' in tileJSON ? tileJSON.scheme : 'quadbin';
222294
return mergeLoadOptions(super.getLoadOptions(), {
223295
fetch: {headers: {Authorization: `Bearer ${tileJSON.accessToken}`}},
224-
cartoSpatialTile: {scheme: 'quadbin'}
296+
cartoSpatialTile: {scheme}
225297
});
226298
}
227299

@@ -230,13 +302,16 @@ export default class ClusterTileLayer<
230302
if (!tileJSON) return null;
231303

232304
const {tiles: data, maxresolution: maxZoom} = tileJSON;
305+
const isH3 = tileJSON && 'scheme' in tileJSON && tileJSON.scheme === 'h3';
306+
const TilesetClass = isH3 ? H3Tileset2D : QuadbinTileset2D;
307+
233308
return [
234309
// @ts-ignore
235310
new ClusterGeoJsonLayer(this.props, {
236311
id: `cluster-geojson-layer-${this.props.id}`,
237312
data,
238313
// TODO: Tileset2D should be generic over TileIndex type
239-
TilesetClass: QuadbinTileset2D as any,
314+
TilesetClass: TilesetClass as any,
240315
maxZoom,
241316
loadOptions: this.getLoadOptions()
242317
})

modules/carto/src/layers/cluster-utils.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Copyright (c) vis.gl contributors
44

55
import {cellToParent} from 'quadbin';
6+
import {cellToParent as h3CellToParent, getResolution as getH3Resolution} from 'h3-js';
67
import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers';
78
import {Accessor, log} from '@deck.gl/core';
89
import {BinaryFeatureCollection} from '@loaders.gl/schema';
@@ -14,25 +15,34 @@ export type AggregationProperties<FeaturePropertiesT> = {
1415
name: keyof FeaturePropertiesT;
1516
}[];
1617
export type ClusteredFeaturePropertiesT<FeaturePropertiesT> = FeaturePropertiesT & {
17-
id: bigint;
18+
id: bigint | string;
1819
count: number;
1920
position: [number, number];
2021
};
2122
export type ParsedQuadbinCell<FeaturePropertiesT> = {id: bigint; properties: FeaturePropertiesT};
2223
export type ParsedQuadbinTile<FeaturePropertiesT> = ParsedQuadbinCell<FeaturePropertiesT>[];
24+
export type ParsedH3Cell<FeaturePropertiesT> = {id: string; properties: FeaturePropertiesT};
25+
export type ParsedH3Tile<FeaturePropertiesT> = ParsedH3Cell<FeaturePropertiesT>[];
2326

2427
/**
2528
* Aggregates tile by specified properties, caching result in tile.userData
2629
*
2730
* @returns true if data was aggregated, false if cache used
2831
*/
2932
export function aggregateTile<FeaturePropertiesT>(
30-
tile: Tile2DHeader<ParsedQuadbinTile<FeaturePropertiesT>>,
33+
tile: Tile2DHeader<ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>>,
3134
tileAggregationCache: Map<number, ClusteredFeaturePropertiesT<FeaturePropertiesT>[]>,
3235
aggregationLevels: number,
3336
properties: AggregationProperties<FeaturePropertiesT> = [],
34-
getPosition: Accessor<ParsedQuadbinCell<FeaturePropertiesT>, [number, number]>,
35-
getWeight: Accessor<ParsedQuadbinCell<FeaturePropertiesT>, number>
37+
getPosition: Accessor<
38+
ParsedQuadbinCell<FeaturePropertiesT> | ParsedH3Cell<FeaturePropertiesT>,
39+
[number, number]
40+
>,
41+
getWeight: Accessor<
42+
ParsedQuadbinCell<FeaturePropertiesT> | ParsedH3Cell<FeaturePropertiesT>,
43+
number
44+
>,
45+
scheme: 'quadbin' | 'h3' = 'quadbin'
3646
): boolean {
3747
if (!tile.content) return false;
3848

@@ -50,19 +60,24 @@ export function aggregateTile<FeaturePropertiesT>(
5060
tileAggregationCache.clear();
5161
}
5262

53-
const out: Record<number, any> = {};
63+
const out: Record<string, any> = {};
5464
for (const cell of tile.content) {
5565
let id = cell.id;
5666
const position = typeof getPosition === 'function' ? getPosition(cell, {} as any) : getPosition;
5767

5868
// Aggregate by parent rid
5969
for (let i = 0; i < aggregationLevels - 1; i++) {
60-
id = cellToParent(id);
70+
if (scheme === 'h3') {
71+
const currentResolution = getH3Resolution(id as string);
72+
id = h3CellToParent(id as string, Math.max(0, currentResolution - 1));
73+
} else {
74+
id = cellToParent(id as bigint);
75+
}
6176
}
6277

63-
// Unfortunately TS doesn't support Record<bigint, any>
78+
// Use string key for both H3 and Quadbin to avoid TypeScript Record<bigint, any> issues
6479
// https://github.com/microsoft/TypeScript/issues/46395
65-
const parentId = Number(id);
80+
const parentId = String(id);
6681
if (!(parentId in out)) {
6782
out[parentId] = {id, count: 0, position: [0, 0]};
6883
for (const {name, aggregation} of properties) {
@@ -104,7 +119,7 @@ export function aggregateTile<FeaturePropertiesT>(
104119
}
105120

106121
export function extractAggregationProperties<FeaturePropertiesT extends {}>(
107-
tile: Tile2DHeader<ParsedQuadbinTile<FeaturePropertiesT>>
122+
tile: Tile2DHeader<ParsedQuadbinTile<FeaturePropertiesT> | ParsedH3Tile<FeaturePropertiesT>>
108123
): AggregationProperties<FeaturePropertiesT> {
109124
const properties: AggregationProperties<FeaturePropertiesT> = [];
110125
const validAggregations: Aggregation[] = ['any', 'average', 'count', 'min', 'max', 'sum'];

0 commit comments

Comments
 (0)