Skip to content

Commit

Permalink
CARTO: HeatmapTileLayer full colorRange (#9068)
Browse files Browse the repository at this point in the history
  • Loading branch information
felixpalmer committed Nov 7, 2024
1 parent eb03446 commit 2aa3706
Show file tree
Hide file tree
Showing 2 changed files with 119 additions and 100 deletions.
89 changes: 86 additions & 3 deletions modules/carto/src/layers/heatmap-tile-layer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import {getResolution} from 'quadbin';

import {Accessor, CompositeLayer, CompositeLayerProps, DefaultProps, Layer} from '@deck.gl/core';
import {
Accessor,
Color,
CompositeLayer,
CompositeLayerProps,
DefaultProps,
Layer,
UpdateParameters
} from '@deck.gl/core';
import {SolidPolygonLayer} from '@deck.gl/layers';

import {HeatmapProps, heatmap} from './heatmap';
Expand All @@ -9,7 +17,27 @@ import QuadbinTileLayer, {QuadbinTileLayerProps} from './quadbin-tile-layer';
import {TilejsonPropType} from './utils';
import {TilejsonResult} from '../sources';
import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers';
import {Texture, TextureProps} from '@luma.gl/core';

const defaultColorRange: Color[] = [
[255, 255, 178],
[254, 217, 118],
[254, 178, 76],
[253, 141, 60],
[240, 59, 32],
[189, 0, 38]
];

const TEXTURE_PROPS: TextureProps = {
format: 'rgba8unorm',
mipmaps: false,
sampler: {
minFilter: 'linear',
magFilter: 'linear',
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge'
}
};
/**
* Computes the unit density (inverse of cell area)
*/
Expand All @@ -18,6 +46,24 @@ function unitDensityForCell(cell: bigint) {
return Math.pow(4.0, cellResolution);
}

/**
* Converts a colorRange array to a flat array with 4 components per color
*/
function colorRangeToFlatArray(colorRange: Color[]): Uint8Array {
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] as number) : 255;
}

return flatArray;
}

// Modified polygon layer to draw offscreen and output value expected by heatmap
class RTTSolidPolygonLayer extends RTTModifier(SolidPolygonLayer) {
static layerName = 'RTTSolidPolygonLayer';
Expand Down Expand Up @@ -70,6 +116,7 @@ const defaultProps: DefaultProps<HeatmapTileLayerProps> = {
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}
};
Expand All @@ -83,6 +130,13 @@ export type HeatmapTileLayerProps<DataT = unknown> = _HeatmapTileLayerProps<Data
/** Properties added by HeatmapTileLayer. */
type _HeatmapTileLayerProps<DataT> = QuadbinTileLayerProps<DataT> &
HeatmapProps & {
/**
* Specified as an array of colors [color1, color2, ...].
*
* @default `6-class YlOrRd` - [colorbrewer](http://colorbrewer2.org/#type=sequential&scheme=YlOrRd&n=6)
*/
colorRange: Color[];

/**
* The weight of each object.
*
Expand All @@ -101,6 +155,7 @@ class HeatmapTileLayer<DataT = any, ExtraProps extends {} = {}> extends Composit
static defaultProps = defaultProps;

state!: {
colorTexture?: Texture;
isLoaded: boolean;
tiles: Set<Tile2DHeader>;
viewportChanged?: boolean;
Expand All @@ -119,12 +174,19 @@ class HeatmapTileLayer<DataT = any, ExtraProps extends {} = {}> extends Composit
return changeFlags.somethingChanged;
}

updateState(opts: UpdateParameters<this>) {
const {props, oldProps} = opts;
super.updateState(opts);
if (props.colorRange !== oldProps.colorRange) {
this._updateColorTexture(opts);
}
}

renderLayers(): Layer {
const {
data,
getWeight,
colorDomain,
colorRange,
intensity,
radiusPixels,
_subLayerProps,
Expand Down Expand Up @@ -183,12 +245,13 @@ class HeatmapTileLayer<DataT = any, ExtraProps extends {} = {}> extends Composit

colorDomain,

colorRange,
radiusPixels,
intensity,
_subLayerProps: subLayerProps,
refinementStrategy: 'no-overlap',

colorTexture: this.state.colorTexture,

// Disable line rendering
extruded: false,
stroked: false,
Expand Down Expand Up @@ -231,6 +294,26 @@ class HeatmapTileLayer<DataT = any, ExtraProps extends {} = {}> extends Composit
})
);
}

_updateColorTexture(opts) {
const {colorRange} = opts.props;
let {colorTexture} = this.state;
const colors = colorRangeToFlatArray(colorRange);

if (colorTexture && colorTexture?.width === colorRange.length) {
// TODO(v9): Unclear whether `setSubImageData` is a public API, or what to use if not.
(colorTexture as any).setSubImageData({data: colors});
} else {
colorTexture?.destroy();
colorTexture = this.context.device.createTexture({
...TEXTURE_PROPS,
data: colors,
width: colorRange.length,
height: 1
});
}
this.setState({colorTexture});
}
}

export default HeatmapTileLayer;
130 changes: 33 additions & 97 deletions modules/carto/src/layers/heatmap.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,29 @@
import type {ShaderPass} from '@luma.gl/shadertools';
import {Color} from '@deck.gl/core';
import {Texture} from '@luma.gl/core';
const glsl = (s: TemplateStringsArray) => `${s}`;

/**
* @filter Heatmap
* @param radiusPixels Blur radius in pixels, controls smoothness of heatmap
* @param colorDomain Domain to apply to values prior to applying color scale
* @param color1-6 Colors to use in color scale
* @param colorTexture 1D RGB lookup texture for color scale
* @param intensity Multiplier to apply to value
* @param opacity Output opacity
*/

const fs = glsl`\
uniform heatmapUniforms {
vec2 delta;
float radiusPixels;
vec2 colorDomain;
vec3 color1;
vec3 color2;
vec3 color3;
vec3 color4;
vec3 color5;
vec3 color6;
vec2 delta;
float intensity;
float opacity;
float radiusPixels;
} heatmap;
const vec4 STOPS = vec4(0.2, 0.4, 0.6, 0.8);
uniform sampler2D colorTexture;
vec3 colorGradient(float value) {
vec3 c1;
vec3 c2;
vec2 range;
if (value < STOPS.x) {
range = vec2(0.0, STOPS.x);
c1 = heatmap.color1; c2 = heatmap.color2;
} else if (value < STOPS.y) {
range = STOPS.xy;
c1 = heatmap.color2; c2 = heatmap.color3;
} else if (value < STOPS.z) {
range = STOPS.yz;
c1 = heatmap.color3; c2 = heatmap.color4;
} else if (value < STOPS.w) {
range = STOPS.zw;
c1 = heatmap.color4; c2 = heatmap.color5;
} else {
range = vec2(STOPS.w, 1.0);
c1 = heatmap.color5; c2 = heatmap.color6;
}
float f = (clamp(value, 0.0, 1.0) - range.x) / (range.y - range.x);
return mix(c1, c2, f) / 255.0;
return texture(colorTexture, vec2(value, 0.5)).rgb;
}
const vec3 SHIFT = vec3(1.0, 256.0, 256.0 * 256.0);
Expand Down Expand Up @@ -115,15 +90,6 @@ vec4 heatmap_sampleColor(sampler2D source, vec2 texSize, vec2 texCoord) {
}
`;

const defaultColorRange: Color[] = [
[255, 255, 178],
[254, 217, 118],
[254, 178, 76],
[253, 141, 60],
[240, 59, 32],
[189, 0, 38]
];

export type HeatmapProps = {
/**
* Radius of the heatmap blur in pixels, to which the weight of a cell is distributed.
Expand All @@ -132,94 +98,64 @@ export type HeatmapProps = {
*/
radiusPixels?: number;
/**
* Controls how weight values are mapped to the `colorRange`, as an array of two numbers [`minValue`, `maxValue`].
* Controls how weight values are mapped to the colors in `colorTexture`, as an array of two numbers [`minValue`, `maxValue`].
*
* @default [0, 1]
*/
colorDomain?: [number, number];
/**
* Specified as an array of colors [color1, color2, ...].
*
* @default `6-class YlOrRd` - [colorbrewer](http://colorbrewer2.org/#type=sequential&scheme=YlOrRd&n=6)
*/
colorRange: Color[];
/**
* Value that is multiplied with the total weight at a pixel to obtain the final weight. A value larger than 1 biases the output color towards the higher end of the spectrum, and a value less than 1 biases the output color towards the lower end of the spectrum.
*/
intensity?: number;
/**
* Color LUT for color gradient
*/
colorTexture: Texture;
opacity: number;
};

export type HeatmapUniforms = {
delta?: [number, number];
radiusPixels?: number;
colorDomain?: [number, number];
color1?: [number, number, number];
color2?: [number, number, number];
color3?: [number, number, number];
color4?: [number, number, number];
color5?: [number, number, number];
color6?: [number, number, number];
intensity: number;
opacity?: number;
type PassProps = {
delta: [number, number];
};

export const heatmap: ShaderPass<HeatmapProps, HeatmapUniforms> = {
export const heatmap = {
name: 'heatmap',
uniformPropTypes: {
delta: {value: [0, 1]},
radiusPixels: {value: 20, min: 0, softMax: 100},
colorDomain: {value: [0, 1]},
color1: {value: [0, 0, 0]},
color2: {value: [0, 0, 0]},
color3: {value: [0, 0, 0]},
color4: {value: [0, 0, 0]},
color5: {value: [0, 0, 0]},
color6: {value: [0, 0, 0]},
delta: {value: [0, 1]},
intensity: {value: 1, min: 0.1, max: 10},
opacity: {value: 1, min: 0, max: 1}
opacity: {value: 1, min: 0, max: 1},
radiusPixels: {value: 20, min: 0, softMax: 100}
},
uniformTypes: {
delta: 'vec2<f32>',
radiusPixels: 'f32',
colorDomain: 'vec2<f32>',
color1: 'vec3<f32>',
color2: 'vec3<f32>',
color3: 'vec3<f32>',
color4: 'vec3<f32>',
color5: 'vec3<f32>',
color6: 'vec3<f32>',
delta: 'vec2<f32>',
intensity: 'f32',
opacity: 'f32'
opacity: 'f32',
radiusPixels: 'f32'
},
// @ts-expect-error stricter luma gl types
getUniforms: opts => {
if (!opts) return {};
const {
delta = [1, 0],
colorRange = defaultColorRange,
radiusPixels = 20,
colorDomain = [0, 1],
colorTexture,
delta = [1, 0],
intensity = 1,
opacity = 1
} = opts as HeatmapProps & {delta: [number, number]};
const [color1, color2, color3, color4, color5, color6] = colorRange;
opacity = 1,
radiusPixels = 20
} = opts;
return {
delta,
color1,
color2,
color3,
color4,
color5,
color6,
radiusPixels,
colorDomain,
colorTexture,
delta,
intensity,
opacity
opacity,
radiusPixels
};
},
fs,
passes: [
{sampler: true, uniforms: {delta: [1, 0]}},
{sampler: true, uniforms: {delta: [0, 1]}}
]
};
} as const satisfies ShaderPass<HeatmapProps & PassProps>;

0 comments on commit 2aa3706

Please sign in to comment.