Skip to content

Commit 0d3c3b3

Browse files
authored
CARTO: Support h3 in HeatmapTileLayer (#9753)
1 parent 18e9c34 commit 0d3c3b3

File tree

10 files changed

+575
-38
lines changed

10 files changed

+575
-38
lines changed

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

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66

77
import type {ShaderModule} from '@luma.gl/shadertools';
88
import {getResolution} from 'quadbin';
9+
import {getResolution as getH3Resolution, getNumCells} from 'h3-js';
910

1011
import {
1112
Accessor,
1213
Color,
1314
CompositeLayer,
1415
CompositeLayerProps,
16+
_deepEqual as deepEqual,
1517
DefaultProps,
1618
Layer,
1719
UpdateParameters
@@ -21,6 +23,7 @@ import {SolidPolygonLayer} from '@deck.gl/layers';
2123
import {HeatmapProps, heatmap} from './heatmap';
2224
import {RTTModifier, PostProcessModifier} from './post-process-utils';
2325
import QuadbinTileLayer, {QuadbinTileLayerProps} from './quadbin-tile-layer';
26+
import H3TileLayer, {H3TileLayerProps} from './h3-tile-layer';
2427
import {TilejsonPropType} from './utils';
2528
import {TilejsonResult} from '@carto/api-client';
2629
import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers';
@@ -48,13 +51,23 @@ const TEXTURE_PROPS: TextureProps = {
4851
}
4952
};
5053
/**
51-
* Computes the unit density (inverse of cell area)
54+
* Computes the unit density for Quadbin cells.
55+
* The unit density is the number of cells needed to cover the Earth's surface at a given resolution. It is inversely proportional to the cell area.
5256
*/
53-
function unitDensityForCell(cell: bigint) {
57+
function unitDensityForQuadbinCell(cell: bigint) {
5458
const cellResolution = Number(getResolution(cell));
5559
return Math.pow(4.0, cellResolution);
5660
}
5761

62+
/**
63+
* Computes the unit density for H3 cells.
64+
* The unit density is the number of cells needed to cover the Earth's surface at a given resolution. It is inversely proportional to the cell area.
65+
*/
66+
function unitDensityForH3Cell(cellId: string) {
67+
const cellResolution = Number(getH3Resolution(cellId));
68+
return getNumCells(cellResolution);
69+
}
70+
5871
/**
5972
* Converts a colorRange array to a flat array with 4 components per color
6073
*/
@@ -120,12 +133,15 @@ class RTTSolidPolygonLayer extends RTTModifier(SolidPolygonLayer) {
120133

121134
draw(this, opts: any) {
122135
const cell = this.props!.data[0];
123-
const maxDensity = this.props.elevationScale;
124-
const densityProps: DensityProps = {
125-
factor: unitDensityForCell(cell.id) / maxDensity
126-
};
127-
for (const model of this.state.models) {
128-
model.shaderInputs.setProps({density: densityProps});
136+
if (cell) {
137+
const maxDensity = this.props.elevationScale;
138+
const {scheme} = this.parent.parent.parent.parent.parent.state;
139+
const unitDensity =
140+
scheme === 'h3' ? unitDensityForH3Cell(cell.id) : unitDensityForQuadbinCell(cell.id);
141+
const densityProps: DensityProps = {factor: unitDensity / maxDensity};
142+
for (const model of this.state.models) {
143+
model.shaderInputs.setProps({density: densityProps});
144+
}
129145
}
130146

131147
super.draw(opts);
@@ -135,6 +151,9 @@ class RTTSolidPolygonLayer extends RTTModifier(SolidPolygonLayer) {
135151
// Modify QuadbinTileLayer to apply heatmap post process effect
136152
const PostProcessQuadbinTileLayer = PostProcessModifier(QuadbinTileLayer, heatmap);
137153

154+
// Modify H3TileLayer to apply heatmap post process effect
155+
const PostProcessH3TileLayer = PostProcessModifier(H3TileLayer, heatmap);
156+
138157
const defaultProps: DefaultProps<HeatmapTileLayerProps> = {
139158
data: TilejsonPropType,
140159
getWeight: {type: 'accessor', value: 1},
@@ -152,7 +171,7 @@ export type HeatmapTileLayerProps<DataT = unknown> = _HeatmapTileLayerProps<Data
152171
};
153172

154173
/** Properties added by HeatmapTileLayer. */
155-
type _HeatmapTileLayerProps<DataT> = QuadbinTileLayerProps<DataT> &
174+
type _HeatmapTileLayerProps<DataT> = (QuadbinTileLayerProps<DataT> | H3TileLayerProps<DataT>) &
156175
HeatmapProps & {
157176
/**
158177
* Specified as an array of colors [color1, color2, ...].
@@ -181,12 +200,14 @@ class HeatmapTileLayer<DataT = any, ExtraProps extends {} = {}> extends Composit
181200
state!: {
182201
colorTexture?: Texture;
183202
isLoaded: boolean;
203+
scheme: string | null;
184204
tiles: Set<Tile2DHeader>;
185205
viewportChanged?: boolean;
186206
};
187207
initializeState() {
188208
this.state = {
189209
isLoaded: false,
210+
scheme: null,
190211
tiles: new Set(),
191212
viewportChanged: false
192213
};
@@ -201,9 +222,15 @@ class HeatmapTileLayer<DataT = any, ExtraProps extends {} = {}> extends Composit
201222
updateState(opts: UpdateParameters<this>) {
202223
const {props, oldProps} = opts;
203224
super.updateState(opts);
204-
if (props.colorRange !== oldProps.colorRange) {
225+
if (!deepEqual(props.colorRange, oldProps.colorRange, 2)) {
205226
this._updateColorTexture(opts);
206227
}
228+
229+
const scheme = props.data && 'scheme' in props.data ? props.data.scheme : null;
230+
if (this.state.scheme !== scheme) {
231+
this.setState({scheme});
232+
this.state.tiles.clear();
233+
}
207234
}
208235

209236
renderLayers(): Layer {
@@ -222,15 +249,18 @@ class HeatmapTileLayer<DataT = any, ExtraProps extends {} = {}> extends Composit
222249
...tileLayerProps
223250
} = this.props;
224251

252+
const isH3 = this.state.scheme === 'h3';
253+
254+
const cellLayerName = isH3 ? 'hexagon-cell-hifi' : 'cell';
225255
// Inject modified polygon layer as sublayer into TileLayer
226256
const subLayerProps = {
227257
..._subLayerProps,
228-
cell: {
229-
..._subLayerProps?.cell,
258+
[cellLayerName]: {
259+
..._subLayerProps?.[cellLayerName],
230260
_subLayerProps: {
231-
..._subLayerProps?.cell?._subLayerProps,
261+
..._subLayerProps?.[cellLayerName]?._subLayerProps,
232262
fill: {
233-
..._subLayerProps?.cell?._subLayerProps?.fill,
263+
..._subLayerProps?.[cellLayerName]?._subLayerProps?.fill,
234264
type: RTTSolidPolygonLayer
235265
}
236266
}
@@ -248,21 +278,37 @@ class HeatmapTileLayer<DataT = any, ExtraProps extends {} = {}> extends Composit
248278

249279
for (const tile of tiles) {
250280
const cell = tile.content[0];
251-
const unitDensity = unitDensityForCell(cell.id);
281+
const unitDensity = isH3 ? unitDensityForH3Cell(cell.id) : unitDensityForQuadbinCell(cell.id);
252282
maxDensity = Math.max(tile.userData!.maxWeight * unitDensity, maxDensity);
253283
tileZ = Math.max(tile.zoom, tileZ);
254284
}
255285

256-
// Between zoom levels the max density will change, but it isn't possible to know by what factor. Uniform data distributions lead to a factor of 4, while very localized data gives 1. As a heurstic estimate with a value inbetween (2) to make the transitions less obvious.
257-
const overzoom = this.context.viewport.zoom - tileZ;
258-
const estimatedMaxDensity = maxDensity * Math.pow(2, overzoom);
286+
// Between zoom levels the max density will change, but it isn't possible to know by what factor.
287+
// As a heuristic, an estimatedGrowthFactor makes the transitions less obvious.
288+
// For quadbin, uniform data distributions lead to an estimatedGrowthFactor of 4, while very localized data gives 1.
289+
// For H3 the same logic applies but the aperture is 7, rather than 4, so a slightly higher estimatedGrowthFactor is used.
290+
let overzoom: number;
291+
let estimatedGrowthFactor: number;
292+
if (isH3) {
293+
// For H3, we need to account for the viewport zoom to H3 resolution mapping (see getHexagonResolution())
294+
overzoom = (2 / 3) * this.context.viewport.zoom - tileZ - 2.25;
295+
estimatedGrowthFactor = 2.2;
296+
} else {
297+
overzoom = this.context.viewport.zoom - tileZ;
298+
estimatedGrowthFactor = 2;
299+
}
259300

260-
maxDensity = estimatedMaxDensity;
301+
maxDensity = maxDensity * Math.pow(estimatedGrowthFactor, overzoom);
261302
if (typeof onMaxDensityChange === 'function') {
262303
onMaxDensityChange(maxDensity);
263304
}
264-
return new PostProcessQuadbinTileLayer(
265-
tileLayerProps as Omit<QuadbinTileLayerProps, 'data'>,
305+
const PostProcessTileLayer = isH3 ? PostProcessH3TileLayer : PostProcessQuadbinTileLayer;
306+
const layerProps = isH3
307+
? (tileLayerProps as Omit<H3TileLayerProps, 'data'>)
308+
: (tileLayerProps as Omit<QuadbinTileLayerProps, 'data'>);
309+
310+
return new PostProcessTileLayer(
311+
layerProps,
266312
this.getSubLayerProps({
267313
id: 'heatmap',
268314
data,
@@ -330,18 +376,14 @@ class HeatmapTileLayer<DataT = any, ExtraProps extends {} = {}> extends Composit
330376
let {colorTexture} = this.state;
331377
const colors = colorRangeToFlatArray(colorRange);
332378

333-
if (colorTexture && colorTexture?.width === colorRange.length) {
334-
// TODO(v9): Unclear whether `setSubImageData` is a public API, or what to use if not.
335-
(colorTexture as any).setTexture2DData({data: colors});
336-
} else {
337-
colorTexture?.destroy();
338-
colorTexture = this.context.device.createTexture({
339-
...TEXTURE_PROPS,
340-
data: colors,
341-
width: colorRange.length,
342-
height: 1
343-
});
344-
}
379+
colorTexture?.destroy();
380+
colorTexture = this.context.device.createTexture({
381+
...TEXTURE_PROPS,
382+
data: colors,
383+
width: colorRange.length,
384+
height: 1
385+
});
386+
345387
this.setState({colorTexture});
346388
}
347389
}

modules/carto/src/layers/heatmap.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ vec4 heatmap_sampleColor(sampler2D source, vec2 texSize, vec2 texCoord) {
8989
color.a = pow(color.a, 1.0 / 2.2);
9090
color.a *= heatmap.opacity;
9191
92+
// Use premultiplied alpha for compatibility with blending in ScreenPass
93+
color.rgb *= color.a;
94+
9295
return color;
9396
}
9497
`;

test/apps/carto-dynamic-tile/app.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import React, {useState} from 'react';
88
import {createRoot} from 'react-dom/client';
99
import {Map} from 'react-map-gl/maplibre';
1010
import DeckGL from '@deck.gl/react';
11-
import {H3TileLayer, RasterTileLayer, QuadbinTileLayer, VectorTileLayer} from '@deck.gl/carto';
11+
import {
12+
H3TileLayer,
13+
HeatmapTileLayer,
14+
RasterTileLayer,
15+
QuadbinTileLayer,
16+
VectorTileLayer
17+
} from '@deck.gl/carto';
1218

1319
import {query} from '@carto/api-client';
1420
import datasets from './datasets';
@@ -31,6 +37,8 @@ function Root() {
3137

3238
if (dataset.includes('boundary')) {
3339
layers = [useBoundaryLayer(datasource)];
40+
} else if (dataset.includes('heatmap')) {
41+
layers = [useHeatmapLayer(datasource)];
3442
} else if (dataset.includes('h3')) {
3543
layers = [useH3Layer(datasource)];
3644
} else if (dataset.includes('raster')) {
@@ -89,6 +97,35 @@ function useBoundaryLayer(datasource) {
8997
});
9098
}
9199

100+
function useHeatmapLayer(datasource) {
101+
const {getWeight, source, aggregationExp, columns, spatialDataColumn, sqlQuery, tableName} =
102+
datasource;
103+
const tilejson = source({
104+
...globalOptions,
105+
aggregationExp,
106+
columns,
107+
spatialDataColumn,
108+
sqlQuery,
109+
tableName
110+
});
111+
112+
return new HeatmapTileLayer({
113+
id: 'carto-heatmap',
114+
data: tilejson,
115+
getWeight,
116+
radiusPixels: 30,
117+
intensity: 2,
118+
colorRange: [
119+
[255, 255, 178],
120+
[254, 217, 118],
121+
[254, 178, 76],
122+
[253, 141, 60],
123+
[240, 59, 32],
124+
[189, 0, 38]
125+
]
126+
});
127+
}
128+
92129
function useH3Layer(datasource) {
93130
const {getFillColor, source, aggregationExp, columns, spatialDataColumn, sqlQuery, tableName} =
94131
datasource;

test/apps/carto-dynamic-tile/datasets.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,36 @@ import {
1414
quadbinQuerySource,
1515
vectorTableSource,
1616
vectorTilesetSource,
17-
vectorQuerySource,
18-
colorBins
19-
} from '@deck.gl/carto';
17+
vectorQuerySource
18+
} from '@carto/api-client';
19+
20+
import {colorBins} from '@deck.gl/carto';
2021

2122
export default {
23+
'heatmap-h3-table': {
24+
source: h3TableSource,
25+
tableName: 'carto-demo-data.demo_tables.derived_spatialfeatures_usa_h3res8_v1_yearly_v2',
26+
aggregationExp: 'sum(population) as population_sum',
27+
getWeight: d => d.properties.population_sum || 1
28+
},
29+
'heatmap-h3-tileset': {
30+
source: h3TilesetSource,
31+
tableName:
32+
'carto-demo-data.demo_tilesets.derived_spatialfeatures_usa_h3res8_v1_yearly_v2_tileset',
33+
getWeight: d => d.properties.retail || 1
34+
},
35+
'heatmap-quadbin-table': {
36+
source: quadbinTableSource,
37+
tableName: 'carto-demo-data.demo_tables.derived_spatialfeatures_usa_quadbin15_v1_yearly_v2',
38+
aggregationExp: 'sum(population) as population_sum',
39+
getWeight: d => d.properties.population_sum || 1
40+
},
41+
'heatmap-quadbin-tileset': {
42+
source: quadbinTilesetSource,
43+
tableName:
44+
'carto-demo-data.demo_tilesets.derived_spatialfeatures_usa_quadbin15_v1_yearly_v2_tileset',
45+
getWeight: d => d.properties.avg_retail || 1
46+
},
2247
'boundary-query': {
2348
source: boundaryQuerySource,
2449
tilesetTableName: 'carto-boundaries.us.usa_zip_code_v1',

test/apps/carto-dynamic-tile/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"start-local": "vite --config ../vite.config.local.mjs"
55
},
66
"dependencies": {
7-
"deck.gl": "^8.0.0",
7+
"@carto/api-client": "^0.5.14",
8+
"deck.gl": "^9.2.0-alpha",
89
"maplibre-gl": "^4.3.2",
910
"react": "^18.0.0",
1011
"react-dom": "^18.0.0",

0 commit comments

Comments
 (0)