@deck.gl/carto
Version:
CARTO official integration with Deck.gl. Build geospatial applications using CARTO and Deck.gl.
280 lines (278 loc) • 11.1 kB
JavaScript
// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import { getResolution } from 'quadbin';
import { getResolution as getH3Resolution, getNumCells } from 'h3-js';
import { CompositeLayer, _deepEqual as deepEqual } from '@deck.gl/core';
import { SolidPolygonLayer } from '@deck.gl/layers';
import { heatmap } from "./heatmap.js";
import { RTTModifier, PostProcessModifier } from "./post-process-utils.js";
import QuadbinTileLayer from "./quadbin-tile-layer.js";
import H3TileLayer from "./h3-tile-layer.js";
import { TilejsonPropType } from "./utils.js";
const defaultColorRange = [
[255, 255, 178],
[254, 217, 118],
[254, 178, 76],
[253, 141, 60],
[240, 59, 32],
[189, 0, 38]
];
const TEXTURE_PROPS = {
format: 'rgba8unorm',
dimension: '2d',
width: 1,
height: 1,
sampler: {
minFilter: 'linear',
magFilter: 'linear',
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge'
}
};
/**
* Computes the unit density for Quadbin cells.
* 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.
*/
function unitDensityForQuadbinCell(cell) {
const cellResolution = Number(getResolution(cell));
return Math.pow(4.0, cellResolution);
}
/**
* Computes the unit density for H3 cells.
* 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.
*/
function unitDensityForH3Cell(cellId) {
const cellResolution = Number(getH3Resolution(cellId));
return getNumCells(cellResolution);
}
/**
* Converts a colorRange array to a flat array with 4 components per color
*/
function colorRangeToFlatArray(colorRange) {
const flatArray = new Uint8Array(colorRange.length * 4);
let index = 0;
for (let i = 0; i < colorRange.length; i++) {
const color = colorRange[i];
flatArray[index++] = color[0];
flatArray[index++] = color[1];
flatArray[index++] = color[2];
flatArray[index++] = Number.isFinite(color[3]) ? color[3] : 255;
}
return flatArray;
}
const uniformBlock = `\
uniform densityUniforms {
float factor;
} density;
`;
const densityUniforms = {
name: 'density',
vs: uniformBlock,
uniformTypes: {
factor: 'f32'
}
};
// Modified polygon layer to draw offscreen and output value expected by heatmap
class RTTSolidPolygonLayer extends RTTModifier(SolidPolygonLayer) {
getShaders(type) {
const shaders = super.getShaders(type);
shaders.inject = {
'vs:#main-end': `
// Value from getWeight accessor
float weight = elevations;
// Keep "power" delivered to screen constant when tiles update
// by outputting normalized density
weight *= density.factor;
// Pack float into 3 channels to pass to heatmap shader
// SCALE value important, as we don't want to saturate
// but also want enough definition to avoid banding
const vec3 SHIFT = vec3(1.0, 256.0, 256.0 * 256.0);
const float MAX_VAL = SHIFT.z * 255.0;
const float SCALE = MAX_VAL / 8.0;
weight *= SCALE;
weight = clamp(weight, 0.0, MAX_VAL);
vColor = vec4(mod(vec3(weight, floor(weight / SHIFT.yz)), 256.0), 255.0) / 255.0;
`
};
shaders.modules = [...shaders.modules, densityUniforms];
return shaders;
}
draw(opts) {
const cell = this.props.data[0];
if (cell) {
const maxDensity = this.props.elevationScale;
const { scheme } = this.parent.parent.parent.parent.parent.state;
const unitDensity = scheme === 'h3' ? unitDensityForH3Cell(cell.id) : unitDensityForQuadbinCell(cell.id);
const densityProps = { factor: unitDensity / maxDensity };
for (const model of this.state.models) {
model.shaderInputs.setProps({ density: densityProps });
}
}
super.draw(opts);
}
}
RTTSolidPolygonLayer.layerName = 'RTTSolidPolygonLayer';
// Modify QuadbinTileLayer to apply heatmap post process effect
const PostProcessQuadbinTileLayer = PostProcessModifier(QuadbinTileLayer, heatmap);
// Modify H3TileLayer to apply heatmap post process effect
const PostProcessH3TileLayer = PostProcessModifier(H3TileLayer, heatmap);
const defaultProps = {
data: TilejsonPropType,
getWeight: { type: 'accessor', value: 1 },
onMaxDensityChange: { type: 'function', optional: true, value: null },
colorDomain: { type: 'array', value: [0, 1] },
colorRange: defaultColorRange,
intensity: { type: 'number', value: 1 },
radiusPixels: { type: 'number', min: 0, max: 100, value: 20 }
};
class HeatmapTileLayer extends CompositeLayer {
initializeState() {
this.state = {
isLoaded: false,
scheme: null,
tiles: new Set(),
viewportChanged: false
};
}
shouldUpdateState({ changeFlags }) {
const { viewportChanged } = changeFlags;
this.setState({ viewportChanged });
return changeFlags.somethingChanged;
}
updateState(opts) {
const { props, oldProps } = opts;
super.updateState(opts);
if (!deepEqual(props.colorRange, oldProps.colorRange, 2)) {
this._updateColorTexture(opts);
}
const scheme = props.data && 'scheme' in props.data ? props.data.scheme : null;
if (this.state.scheme !== scheme) {
this.setState({ scheme });
this.state.tiles.clear();
}
}
renderLayers() {
const { data, getWeight, colorDomain, intensity, radiusPixels, _subLayerProps, updateTriggers, onMaxDensityChange, onViewportLoad, onTileLoad, onTileUnload, ...tileLayerProps } = this.props;
const isH3 = this.state.scheme === 'h3';
const cellLayerName = isH3 ? 'hexagon-cell-hifi' : 'cell';
// Inject modified polygon layer as sublayer into TileLayer
const subLayerProps = {
..._subLayerProps,
[cellLayerName]: {
..._subLayerProps?.[cellLayerName],
_subLayerProps: {
..._subLayerProps?.[cellLayerName]?._subLayerProps,
fill: {
..._subLayerProps?.[cellLayerName]?._subLayerProps?.fill,
type: RTTSolidPolygonLayer
}
}
}
};
let tileZ = 0;
let maxDensity = 0;
const loadedTiles = [...this.state.tiles].filter(t => t.content);
const visibleTiles = loadedTiles.filter(t => t.isVisible);
// As deck.gl initially marks tiles as hidden, use hidden tiles as fallback for calculation.
// This avoids an ugly flash/glitch at startup when layer is first rendered
const tiles = visibleTiles.length ? visibleTiles : loadedTiles;
for (const tile of tiles) {
const cell = tile.content[0];
const unitDensity = isH3 ? unitDensityForH3Cell(cell.id) : unitDensityForQuadbinCell(cell.id);
maxDensity = Math.max(tile.userData.maxWeight * unitDensity, maxDensity);
tileZ = Math.max(tile.zoom, tileZ);
}
// Between zoom levels the max density will change, but it isn't possible to know by what factor.
// As a heuristic, an estimatedGrowthFactor makes the transitions less obvious.
// For quadbin, uniform data distributions lead to an estimatedGrowthFactor of 4, while very localized data gives 1.
// For H3 the same logic applies but the aperture is 7, rather than 4, so a slightly higher estimatedGrowthFactor is used.
let overzoom;
let estimatedGrowthFactor;
if (isH3) {
// For H3, we need to account for the viewport zoom to H3 resolution mapping (see getHexagonResolution())
overzoom = (2 / 3) * this.context.viewport.zoom - tileZ - 2.25;
estimatedGrowthFactor = 2.2;
}
else {
overzoom = this.context.viewport.zoom - tileZ;
estimatedGrowthFactor = 2;
}
maxDensity = maxDensity * Math.pow(estimatedGrowthFactor, overzoom);
if (typeof onMaxDensityChange === 'function') {
onMaxDensityChange(maxDensity);
}
const PostProcessTileLayer = isH3 ? PostProcessH3TileLayer : PostProcessQuadbinTileLayer;
const layerProps = isH3
? tileLayerProps
: tileLayerProps;
return new PostProcessTileLayer(layerProps, this.getSubLayerProps({
id: 'heatmap',
data,
// Re-use existing props to pass down values to sublayer
// TODO replace with custom layer
getFillColor: 0,
getElevation: getWeight,
elevationScale: maxDensity,
colorDomain,
radiusPixels,
intensity,
_subLayerProps: subLayerProps,
refinementStrategy: 'no-overlap',
colorTexture: this.state.colorTexture,
// Disable line rendering
extruded: false,
stroked: false,
updateTriggers: {
getElevation: updateTriggers.getWeight
},
// Tile stats
onViewportLoad: tiles => {
this.setState({ isLoaded: true });
if (typeof onViewportLoad === 'function') {
onViewportLoad(tiles);
}
},
onTileLoad: (tile) => {
let maxWeight = -Infinity;
if (typeof getWeight !== 'function') {
maxWeight = getWeight;
}
else if (tile.content) {
for (const d of tile.content) {
maxWeight = Math.max(getWeight(d, {}), maxWeight);
}
}
tile.userData = { maxWeight };
this.state.tiles.add(tile);
if (typeof onTileLoad === 'function') {
onTileLoad(tile);
}
},
onTileUnload: (tile) => {
this.state.tiles.delete(tile);
if (typeof onTileUnload === 'function') {
onTileUnload(tile);
}
},
transitions: { elevationScale: { type: 'spring', stiffness: 0.3, damping: 0.5 } }
}));
}
_updateColorTexture(opts) {
const { colorRange } = opts.props;
let { colorTexture } = this.state;
const colors = colorRangeToFlatArray(colorRange);
colorTexture?.destroy();
colorTexture = this.context.device.createTexture({
...TEXTURE_PROPS,
data: colors,
width: colorRange.length,
height: 1
});
this.setState({ colorTexture });
}
}
HeatmapTileLayer.layerName = 'HeatmapTileLayer';
HeatmapTileLayer.defaultProps = defaultProps;
export default HeatmapTileLayer;
//# sourceMappingURL=heatmap-tile-layer.js.map