Skip to content

Commit

Permalink
CARTO: ClusterTileLayer (#8957)
Browse files Browse the repository at this point in the history
  • Loading branch information
felixpalmer committed Jul 22, 2024
1 parent 1cbb2b6 commit 6ed31bf
Show file tree
Hide file tree
Showing 8 changed files with 403 additions and 14 deletions.
21 changes: 15 additions & 6 deletions modules/carto/src/api/layer-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<T>(v: T): T {
Expand Down Expand Up @@ -79,12 +87,13 @@ const AGGREGATION_FUNC = {
};

const TILE_LAYER_TYPE_TO_LAYER: Record<TileLayerType, ConstructorOf<Layer>> = {
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 => {
Expand Down
3 changes: 3 additions & 0 deletions modules/carto/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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';
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,
Expand All @@ -14,6 +16,7 @@ const CARTO_LAYERS = {
};
export {
CARTO_LAYERS,
ClusterTileLayer,
H3TileLayer,
HeatmapTileLayer,
_PointLabelLayer,
Expand Down
218 changes: 218 additions & 0 deletions modules/carto/src/layers/cluster-tile-layer.ts
Original file line number Diff line number Diff line change
@@ -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<ClusterTileLayerProps> = {
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<FeaturePropertiesT = {}> = TileLayerPickingInfo<
ParsedQuadbinTile<FeaturePropertiesT>,
PickingInfo<Feature<Geometry, FeaturePropertiesT>>
>;

/** All properties supported by ClusterTileLayer. */
export type ClusterTileLayerProps<FeaturePropertiesT = unknown> =
_ClusterTileLayerProps<FeaturePropertiesT> &
Omit<TileLayerProps<ParsedQuadbinTile<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, 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<ParsedQuadbinCell<FeaturePropertiesT>, [number, number]>;

/**
* The weight of each cell used for clustering.
*
* @default 1
*/
getWeight?: Accessor<ParsedQuadbinCell<FeaturePropertiesT>, number>;
};

class ClusterGeoJsonLayer<
FeaturePropertiesT extends {} = {},
ExtraProps extends {} = {}
> extends TileLayer<
ParsedQuadbinTile<FeaturePropertiesT>,
ExtraProps & Required<_ClusterTileLayerProps<FeaturePropertiesT>>
> {
static layerName = 'ClusterGeoJsonLayer';
static defaultProps = defaultProps;
state!: TileLayer<FeaturePropertiesT>['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<ParsedQuadbinTile<FeaturePropertiesT>>[];
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<FeaturePropertiesT>[];
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<ClusteredFeaturePropertiesT<FeaturePropertiesT>>;

return new GeoJsonLayer(this.getSubLayerProps(props));
}

getPickingInfo(params: GetPickingInfoParams): ClusterTileLayerPickingInfo<FeaturePropertiesT> {
const info = params.info as TileLayerPickingInfo<ParsedQuadbinTile<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;
}
}

// 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 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()
})
];
}
}
Loading

0 comments on commit 6ed31bf

Please sign in to comment.