UNPKG

@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
// 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