diff --git a/modules/aggregation-layers/src/common/types.ts b/modules/aggregation-layers/src/common/types.ts index f5e0892e607..c2cdfdb4e70 100644 --- a/modules/aggregation-layers/src/common/types.ts +++ b/modules/aggregation-layers/src/common/types.ts @@ -9,3 +9,5 @@ export type AggregateAccessor = ( data: any; } ) => number; + +export type ScaleType = 'linear' | 'quantize' | 'quantile' | 'ordinal'; diff --git a/modules/aggregation-layers/src/common/utils/color-utils.ts b/modules/aggregation-layers/src/common/utils/color-utils.ts index 366547df734..b62aa23806f 100644 --- a/modules/aggregation-layers/src/common/utils/color-utils.ts +++ b/modules/aggregation-layers/src/common/utils/color-utils.ts @@ -20,6 +20,7 @@ import type {Color} from '@deck.gl/core'; import type {Device, Texture} from '@luma.gl/core'; import type {NumericArray, TypedArray, TypedArrayConstructor} from '@math.gl/types'; +import type {ScaleType} from '../types'; export const defaultColorRange: Color[] = [ [255, 255, 178], @@ -63,15 +64,33 @@ export function colorRangeToFlatArray( return flatArray; } -export function colorRangeToTexture(device: Device, colorRange: Color[] | NumericArray): Texture { +export const COLOR_RANGE_FILTER: Record = { + linear: 'linear', + quantile: 'nearest', + quantize: 'nearest', + ordinal: 'nearest' +} as const; + +export function updateColorRangeTexture(texture: Texture, type: ScaleType) { + texture.setSampler({ + minFilter: COLOR_RANGE_FILTER[type], + magFilter: COLOR_RANGE_FILTER[type] + }); +} + +export function createColorRangeTexture( + device: Device, + colorRange: Color[] | NumericArray, + type: ScaleType = 'linear' +): Texture { const colors = colorRangeToFlatArray(colorRange, false, Uint8Array); return device.createTexture({ format: 'rgba8unorm', mipmaps: false, sampler: { - minFilter: 'linear', - magFilter: 'linear', + minFilter: COLOR_RANGE_FILTER[type], + magFilter: COLOR_RANGE_FILTER[type], addressModeU: 'clamp-to-edge', addressModeV: 'clamp-to-edge' }, diff --git a/modules/aggregation-layers/src/common/utils/scale-utils.js b/modules/aggregation-layers/src/common/utils/scale-utils.js deleted file mode 100644 index c38f1c9d7c1..00000000000 --- a/modules/aggregation-layers/src/common/utils/scale-utils.js +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright (c) 2015 - 2017 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import {log} from '@deck.gl/core'; - -// a scale function wrapper just like d3-scales -export function getScale(domain, range, scaleFunction) { - const scale = scaleFunction; - scale.domain = () => domain; - scale.range = () => range; - - return scale; -} - -// Quantize scale is similar to linear scales, -// except it uses a discrete rather than continuous range -// return a quantize scale function -export function getQuantizeScale(domain, range) { - const scaleFunction = value => quantizeScale(domain, range, value); - - return getScale(domain, range, scaleFunction); -} - -// return a linear scale function -export function getLinearScale(domain, range) { - const scaleFunction = value => linearScale(domain, range, value); - - return getScale(domain, range, scaleFunction); -} - -export function getQuantileScale(domain, range) { - // calculate threshold - const sortedDomain = domain.sort(ascending); - let i = 0; - const n = Math.max(1, range.length); - const thresholds = new Array(n - 1); - while (++i < n) { - thresholds[i - 1] = threshold(sortedDomain, i / n); - } - - const scaleFunction = value => thresholdsScale(thresholds, range, value); - scaleFunction.thresholds = () => thresholds; - - return getScale(domain, range, scaleFunction); -} - -function ascending(a, b) { - return a - b; -} - -function threshold(domain, fraction) { - const domainLength = domain.length; - if (fraction <= 0 || domainLength < 2) { - return domain[0]; - } - if (fraction >= 1) { - return domain[domainLength - 1]; - } - - const domainFraction = (domainLength - 1) * fraction; - const lowIndex = Math.floor(domainFraction); - const low = domain[lowIndex]; - const high = domain[lowIndex + 1]; - return low + (high - low) * (domainFraction - lowIndex); -} - -function bisectRight(a, x) { - let lo = 0; - let hi = a.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if (ascending(a[mid], x) > 0) { - hi = mid; - } else { - lo = mid + 1; - } - } - return lo; -} - -// return a quantize scale function -function thresholdsScale(thresholds, range, value) { - return range[bisectRight(thresholds, value)]; -} - -// ordinal Scale -function ordinalScale(domain, domainMap, range, value) { - const key = `${value}`; - let d = domainMap.get(key); - if (d === undefined) { - // update the domain - d = domain.push(value); - domainMap.set(key, d); - } - return range[(d - 1) % range.length]; -} - -export function getOrdinalScale(domain, range) { - const domainMap = new Map(); - const uniqueDomain = []; - for (const d of domain) { - const key = `${d}`; - if (!domainMap.has(key)) { - domainMap.set(key, uniqueDomain.push(d)); - } - } - - const scaleFunction = value => ordinalScale(uniqueDomain, domainMap, range, value); - - return getScale(domain, range, scaleFunction); -} - -// Quantize scale is similar to linear scales, -// except it uses a discrete rather than continuous range -export function quantizeScale(domain, range, value) { - const domainRange = domain[1] - domain[0]; - if (domainRange <= 0) { - log.warn('quantizeScale: invalid domain, returning range[0]')(); - return range[0]; - } - const step = domainRange / range.length; - const idx = Math.floor((value - domain[0]) / step); - const clampIdx = Math.max(Math.min(idx, range.length - 1), 0); - - return range[clampIdx]; -} - -// Linear scale maps continuous domain to continuous range -export function linearScale(domain, range, value) { - return ((value - domain[0]) / (domain[1] - domain[0])) * (range[1] - range[0]) + range[0]; -} - -// get scale domains -function notNullOrUndefined(d) { - return d !== undefined && d !== null; -} - -export function unique(values) { - const results = []; - values.forEach(v => { - if (!results.includes(v) && notNullOrUndefined(v)) { - results.push(v); - } - }); - - return results; -} - -function getTruthyValues(data, valueAccessor) { - const values = typeof valueAccessor === 'function' ? data.map(valueAccessor) : data; - return values.filter(notNullOrUndefined); -} - -export function getLinearDomain(data, valueAccessor) { - const sorted = getTruthyValues(data, valueAccessor).sort(); - return sorted.length ? [sorted[0], sorted[sorted.length - 1]] : [0, 0]; -} - -export function getQuantileDomain(data, valueAccessor) { - return getTruthyValues(data, valueAccessor); -} - -export function getOrdinalDomain(data, valueAccessor) { - return unique(getTruthyValues(data, valueAccessor)); -} - -export function getScaleDomain(scaleType, data, valueAccessor) { - switch (scaleType) { - case 'quantize': - case 'linear': - return getLinearDomain(data, valueAccessor); - - case 'quantile': - return getQuantileDomain(data, valueAccessor); - - case 'ordinal': - return getOrdinalDomain(data, valueAccessor); - - default: - return getLinearDomain(data, valueAccessor); - } -} - -export function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -export function getScaleFunctionByScaleType(scaleType) { - switch (scaleType) { - case 'quantize': - return getQuantizeScale; - case 'linear': - return getLinearScale; - case 'quantile': - return getQuantileScale; - case 'ordinal': - return getOrdinalScale; - - default: - return getQuantizeScale; - } -} diff --git a/modules/aggregation-layers/src/common/utils/scale-utils.ts b/modules/aggregation-layers/src/common/utils/scale-utils.ts new file mode 100644 index 00000000000..4810b260569 --- /dev/null +++ b/modules/aggregation-layers/src/common/utils/scale-utils.ts @@ -0,0 +1,232 @@ +import type {BinaryAttribute} from '@deck.gl/core'; +import type {ScaleType} from '../types'; + +type ScaleProps = { + scaleType: ScaleType; + /** Trim the lower end of the domain by this percentile. Set to `0` to disable. */ + lowerPercentile: number; + /** Trim the upper end of the domain by this percentile. Set to `100` to disable. */ + upperPercentile: number; +}; + +/** Applies a scale to BinaryAttribute */ +export class AttributeWithScale { + /** Input values accessor. Has either a `value` (CPU aggregation) or a `buffer` (GPU aggregation) */ + private readonly input: BinaryAttribute; + private readonly inputLength: number; + + private props: ScaleProps = { + scaleType: 'linear', + lowerPercentile: 0, + upperPercentile: 100 + }; + + // cached calculations + private _percentile?: {attribute: BinaryAttribute; domain: number[]}; + private _ordinal?: {attribute: BinaryAttribute; domain: number[]}; + + /** Output values accessor */ + attribute: BinaryAttribute; + /** [min, max] of attribute values, or null if unknown */ + domain: [number, number] | null = null; + /** Valid domain if lower/upper percentile are defined */ + cutoff: [number, number] | null = null; + + constructor(input: BinaryAttribute, inputLength: number) { + this.input = input; + this.inputLength = inputLength; + // No processing is needed with the default scale + this.attribute = input; + } + + private getScalePercentile() { + if (!this._percentile) { + const value = getAttributeValue(this.input, this.inputLength); + this._percentile = applyScaleQuantile(value); + } + return this._percentile; + } + + private getScaleOrdinal() { + if (!this._ordinal) { + const value = getAttributeValue(this.input, this.inputLength); + this._ordinal = applyScaleOrdinal(value); + } + return this._ordinal; + } + + /** Returns the [lowerCutoff, upperCutoff] of scaled values, or null if not applicable */ + private getCutoff({ + scaleType, + lowerPercentile, + upperPercentile + }: ScaleProps): [number, number] | null { + if (scaleType === 'quantile') { + return [lowerPercentile, upperPercentile - 1]; + } + + if (lowerPercentile > 0 || upperPercentile < 100) { + const {domain: thresholds} = this.getScalePercentile(); + let lowValue = thresholds[Math.floor(lowerPercentile) - 1] ?? -Infinity; + let highValue = thresholds[Math.floor(upperPercentile) - 1] ?? Infinity; + + if (scaleType === 'ordinal') { + const {domain: sortedUniqueValues} = this.getScaleOrdinal(); + lowValue = sortedUniqueValues.findIndex(x => x >= lowValue); + highValue = sortedUniqueValues.findIndex(x => x > highValue) - 1; + if (highValue === -2) { + highValue = sortedUniqueValues.length - 1; + } + } + return [lowValue, highValue]; + } + + return null; + } + + update(props: ScaleProps) { + const oldProps = this.props; + + if (props.scaleType !== oldProps.scaleType) { + switch (props.scaleType) { + case 'quantile': { + const {attribute} = this.getScalePercentile(); + this.attribute = attribute; + this.domain = [0, 99]; + break; + } + case 'ordinal': { + const {attribute, domain} = this.getScaleOrdinal(); + this.attribute = attribute; + this.domain = [0, domain.length - 1]; + break; + } + + default: + this.attribute = this.input; + this.domain = null; + } + } + if ( + props.scaleType !== oldProps.scaleType || + props.lowerPercentile !== oldProps.lowerPercentile || + props.upperPercentile !== oldProps.upperPercentile + ) { + this.cutoff = this.getCutoff(props); + } + this.props = props; + return this; + } +} + +/** + * Transform an array of values to ordinal indices + */ +export function applyScaleOrdinal(values: Float32Array): { + attribute: BinaryAttribute; + domain: number[]; +} { + const uniqueValues = new Set(); + for (const x of values) { + if (Number.isFinite(x)) { + uniqueValues.add(x); + } + } + const sortedUniqueValues = Array.from(uniqueValues).sort(); + const domainMap = new Map(); + for (let i = 0; i < sortedUniqueValues.length; i++) { + domainMap.set(sortedUniqueValues[i], i); + } + + return { + attribute: { + value: values.map(x => (Number.isFinite(x) ? domainMap.get(x) : NaN)), + type: 'float32', + size: 1 + }, + domain: sortedUniqueValues + }; +} + +/** + * Transform an array of values to percentiles + */ +export function applyScaleQuantile( + values: Float32Array, + rangeLength = 100 +): { + attribute: BinaryAttribute; + domain: number[]; +} { + const sortedValues = Array.from(values).filter(Number.isFinite).sort(ascending); + let i = 0; + const n = Math.max(1, rangeLength); + const thresholds: number[] = new Array(n - 1); + while (++i < n) { + thresholds[i - 1] = threshold(sortedValues, i / n); + } + return { + attribute: { + value: values.map(x => (Number.isFinite(x) ? bisectRight(thresholds, x) : NaN)), + type: 'float32', + size: 1 + }, + domain: thresholds + }; +} + +function getAttributeValue(attribute: BinaryAttribute, length: number): Float32Array { + const elementStride = (attribute.stride ?? 4) / 4; + const elementOffset = (attribute.offset ?? 0) / 4; + let value = attribute.value as Float32Array; + if (!value) { + const bytes = attribute.buffer?.readSyncWebGL(0, elementStride * 4 * length); + if (bytes) { + value = new Float32Array(bytes.buffer); + attribute.value = value; + } + } + + if (elementStride === 1) { + return value.subarray(0, length); + } + const result = new Float32Array(length); + for (let i = 0; i < length; i++) { + result[i] = value[i * elementStride + elementOffset]; + } + return result; +} + +function ascending(a: number, b: number): number { + return a - b; +} + +function threshold(domain: number[], fraction: number): number { + const domainLength = domain.length; + if (fraction <= 0 || domainLength < 2) { + return domain[0]; + } + if (fraction >= 1) { + return domain[domainLength - 1]; + } + + const domainFraction = (domainLength - 1) * fraction; + const lowIndex = Math.floor(domainFraction); + const low = domain[lowIndex]; + const high = domain[lowIndex + 1]; + return low + (high - low) * (domainFraction - lowIndex); +} + +function bisectRight(a: number[], x: number): number { + let lo = 0; + let hi = a.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (a[mid] > x) { + hi = mid; + } else { + lo = mid + 1; + } + } + return lo; +} diff --git a/modules/aggregation-layers/src/grid-layer/grid-cell-layer-vertex.glsl.ts b/modules/aggregation-layers/src/grid-layer/grid-cell-layer-vertex.glsl.ts index d249e3ea545..11e49390076 100644 --- a/modules/aggregation-layers/src/grid-layer/grid-cell-layer-vertex.glsl.ts +++ b/modules/aggregation-layers/src/grid-layer/grid-cell-layer-vertex.glsl.ts @@ -25,14 +25,19 @@ float interp(float value, vec2 domain, vec2 range) { } vec4 interp(float value, vec2 domain, sampler2D range) { - float r = min(max((value - domain.x) / (domain.y - domain.x), 0.), 1.); + float r = (value - domain.x) / (domain.y - domain.x); return texture(range, vec2(r, 0.5)); } void main(void) { geometry.pickingColor = instancePickingColors; - if (isnan(instanceColorValues)) { + if (isnan(instanceColorValues) || + instanceColorValues < grid.colorDomain.z || + instanceColorValues > grid.colorDomain.w || + instanceElevationValues < grid.elevationDomain.z || + instanceElevationValues > grid.elevationDomain.w + ) { gl_Position = vec4(0.); return; } @@ -44,7 +49,7 @@ void main(void) { // calculate z, if 3d not enabled set to 0 float elevation = 0.0; if (column.extruded) { - elevation = interp(instanceElevationValues, grid.elevationDomain, grid.elevationRange); + elevation = interp(instanceElevationValues, grid.elevationDomain.xy, grid.elevationRange); elevation = project_size(elevation); // cylindar gemoetry height are between -1.0 to 1.0, transform it to between 0, 1 geometry.position.z = (positions.z + 1.0) / 2.0 * elevation; @@ -53,7 +58,7 @@ void main(void) { gl_Position = project_common_position_to_clipspace(geometry.position); DECKGL_FILTER_GL_POSITION(gl_Position, geometry); - vColor = interp(instanceColorValues, grid.colorDomain, colorRange); + vColor = interp(instanceColorValues, grid.colorDomain.xy, colorRange); vColor.a *= layer.opacity; if (column.extruded) { vColor.rgb = lighting_getLightColor(vColor.rgb, project.cameraPosition, geometry.position.xyz, geometry.normal); diff --git a/modules/aggregation-layers/src/grid-layer/grid-cell-layer.ts b/modules/aggregation-layers/src/grid-layer/grid-cell-layer.ts index 42de63edccb..1391e32e583 100644 --- a/modules/aggregation-layers/src/grid-layer/grid-cell-layer.ts +++ b/modules/aggregation-layers/src/grid-layer/grid-cell-layer.ts @@ -6,17 +6,21 @@ import {Texture} from '@luma.gl/core'; import {UpdateParameters, Color} from '@deck.gl/core'; import {ColumnLayer} from '@deck.gl/layers'; import {CubeGeometry} from '@luma.gl/engine'; -import {colorRangeToTexture} from '../common/utils/color-utils'; +import {createColorRangeTexture, updateColorRangeTexture} from '../common/utils/color-utils'; import vs from './grid-cell-layer-vertex.glsl'; import {GridProps, gridUniforms} from './grid-layer-uniforms'; +import type {ScaleType} from '../common/types'; /** Proprties added by GridCellLayer. */ type GridCellLayerProps = { cellSizeCommon: [number, number]; cellOriginCommon: [number, number]; - colorDomain: () => [number, number]; - colorRange?: Color[]; - elevationDomain: () => [number, number]; + colorDomain: [number, number]; + colorCutoff: [number, number] | null; + colorRange: Color[]; + colorScaleType: ScaleType; + elevationDomain: [number, number]; + elevationCutoff: [number, number] | null; elevationRange: [number, number]; }; @@ -73,10 +77,15 @@ export class GridCellLayer extends ColumnLayer< if (oldProps.colorRange !== props.colorRange) { this.state.colorTexture?.destroy(); - this.state.colorTexture = colorRangeToTexture(this.context.device, props.colorRange); - + this.state.colorTexture = createColorRangeTexture( + this.context.device, + props.colorRange, + props.colorScaleType + ); const gridProps: Partial = {colorRange: this.state.colorTexture}; model.shaderInputs.setProps({grid: gridProps}); + } else if (oldProps.colorScaleType !== props.colorScaleType) { + updateColorRangeTexture(this.state.colorTexture, props.colorScaleType); } } @@ -92,16 +101,33 @@ export class GridCellLayer extends ColumnLayer< } draw({uniforms}) { - // Use dynamic domain from the aggregator - const colorDomain = this.props.colorDomain(); - const elevationDomain = this.props.elevationDomain(); - const {cellOriginCommon, cellSizeCommon, elevationRange, elevationScale, extruded, coverage} = - this.props; + const { + cellOriginCommon, + cellSizeCommon, + elevationRange, + elevationScale, + extruded, + coverage, + colorDomain, + elevationDomain + } = this.props; + const colorCutoff = this.props.colorCutoff || [-Infinity, Infinity]; + const elevationCutoff = this.props.elevationCutoff || [-Infinity, Infinity]; const fillModel = this.state.fillModel!; const gridProps: Omit = { - colorDomain, - elevationDomain, + colorDomain: [ + Math.max(colorDomain[0], colorCutoff[0]), // instanceColorValue that maps to colorRange[0] + Math.min(colorDomain[1], colorCutoff[1]), // instanceColorValue that maps to colorRange[colorRange.length - 1] + Math.max(colorDomain[0] - 1, colorCutoff[0]), // hide cell if instanceColorValue is less than this + Math.min(colorDomain[1] + 1, colorCutoff[1]) // hide cell if instanceColorValue is greater than this + ], + elevationDomain: [ + Math.max(elevationDomain[0], elevationCutoff[0]), // instanceElevationValue that maps to elevationRange[0] + Math.min(elevationDomain[1], elevationCutoff[1]), // instanceElevationValue that maps to elevationRange[elevationRange.length - 1] + Math.max(elevationDomain[0] - 1, elevationCutoff[0]), // hide cell if instanceElevationValue is less than this + Math.min(elevationDomain[1] + 1, elevationCutoff[1]) // hide cell if instanceElevationValue is greater than this + ], elevationRange: [elevationRange[0] * elevationScale, elevationRange[1] * elevationScale], originCommon: cellOriginCommon, sizeCommon: cellSizeCommon diff --git a/modules/aggregation-layers/src/grid-layer/grid-layer-uniforms.ts b/modules/aggregation-layers/src/grid-layer/grid-layer-uniforms.ts index 09e01ef58da..506178f282a 100644 --- a/modules/aggregation-layers/src/grid-layer/grid-layer-uniforms.ts +++ b/modules/aggregation-layers/src/grid-layer/grid-layer-uniforms.ts @@ -3,8 +3,8 @@ import type {ShaderModule} from '@luma.gl/shadertools'; const uniformBlock = /* glsl */ `\ uniform gridUniforms { - vec2 colorDomain; - vec2 elevationDomain; + vec4 colorDomain; + vec4 elevationDomain; vec2 elevationRange; vec2 originCommon; vec2 sizeCommon; @@ -12,9 +12,9 @@ uniform gridUniforms { `; export type GridProps = { - colorDomain: [number, number]; + colorDomain: [number, number, number, number]; colorRange: Texture; - elevationDomain: [number, number]; + elevationDomain: [number, number, number, number]; elevationRange: [number, number]; originCommon: [number, number]; sizeCommon: [number, number]; @@ -24,8 +24,8 @@ export const gridUniforms = { name: 'grid', vs: uniformBlock, uniformTypes: { - colorDomain: 'vec2', - elevationDomain: 'vec2', + colorDomain: 'vec4', + elevationDomain: 'vec4', elevationRange: 'vec2', originCommon: 'vec2', sizeCommon: 'vec2' diff --git a/modules/aggregation-layers/src/grid-layer/grid-layer.ts b/modules/aggregation-layers/src/grid-layer/grid-layer.ts index db2b61bbf9b..84c525fcaea 100644 --- a/modules/aggregation-layers/src/grid-layer/grid-layer.ts +++ b/modules/aggregation-layers/src/grid-layer/grid-layer.ts @@ -3,6 +3,7 @@ // Copyright (c) vis.gl contributors import { + log, Accessor, Color, GetPickingInfoParams, @@ -18,11 +19,11 @@ import { UpdateParameters, DefaultProps } from '@deck.gl/core'; -import {getDistanceScales} from '@math.gl/web-mercator'; import {WebGLAggregator, CPUAggregator, AggregationOperation} from '../common/aggregator/index'; import AggregationLayer from '../common/aggregation-layer'; import {AggregateAccessor} from '../common/types'; import {defaultColorRange} from '../common/utils/color-utils'; +import {AttributeWithScale} from '../common/utils/scale-utils'; import {GridCellLayer} from './grid-cell-layer'; import {BinOptions, binOptionsUniforms} from './bin-options-uniforms'; @@ -39,9 +40,9 @@ const defaultProps: DefaultProps = { getColorValue: {type: 'accessor', value: null}, // default value is calculated from `getColorWeight` and `colorAggregation` getColorWeight: {type: 'accessor', value: 1}, colorAggregation: 'SUM', - // lowerPercentile: {type: 'number', min: 0, max: 100, value: 0}, - // upperPercentile: {type: 'number', min: 0, max: 100, value: 100}, - // colorScaleType: 'quantize', + lowerPercentile: {type: 'number', min: 0, max: 100, value: 0}, + upperPercentile: {type: 'number', min: 0, max: 100, value: 100}, + colorScaleType: 'quantize', onSetColorDomain: noop, // elevation @@ -51,9 +52,9 @@ const defaultProps: DefaultProps = { getElevationWeight: {type: 'accessor', value: 1}, elevationAggregation: 'SUM', elevationScale: {type: 'number', min: 0, value: 1}, - // elevationLowerPercentile: {type: 'number', min: 0, max: 100, value: 0}, - // elevationUpperPercentile: {type: 'number', min: 0, max: 100, value: 100}, - // elevationScaleType: 'linear', + elevationLowerPercentile: {type: 'number', min: 0, max: 100, value: 0}, + elevationUpperPercentile: {type: 'number', min: 0, max: 100, value: 100}, + elevationScaleType: 'linear', onSetElevationDomain: noop, // grid @@ -73,7 +74,8 @@ export type GridLayerProps = _GridLayerProps & Composite /** Properties added by GridLayer. */ type _GridLayerProps = { /** - * Accessor to retrieve a grid bin index from each data object. + * Custom accessor to retrieve a grid bin index from each data object. + * Not supported by GPU aggregation. */ gridAggregator?: ((position: number[], cellSize: number) => [number, number]) | null; @@ -124,21 +126,19 @@ type _GridLayerProps = { */ extruded?: boolean; - // TODO - v9 /** * Filter cells and re-calculate color by `upperPercentile`. * Cells with value larger than the upperPercentile will be hidden. * @default 100 */ - // upperPercentile?: number; + upperPercentile?: number; - // TODO - v9 /** * Filter cells and re-calculate color by `lowerPercentile`. * Cells with value smaller than the lowerPercentile will be hidden. * @default 0 */ - // lowerPercentile?: number; + lowerPercentile?: number; /** * Filter cells and re-calculate elevation by `elevationUpperPercentile`. @@ -154,19 +154,19 @@ type _GridLayerProps = { */ elevationLowerPercentile?: number; - // TODO - v9 /** - * Scaling function used to determine the color of the grid cell, default value is 'quantize'. + * Scaling function used to determine the color of the grid cell. * Supported Values are 'quantize', 'linear', 'quantile' and 'ordinal'. * @default 'quantize' */ - // colorScaleType?: 'quantize' | 'linear' | 'quantile' | 'ordinal'; + colorScaleType?: 'quantize' | 'linear' | 'quantile' | 'ordinal'; - // TODO - v9 /** - * Scaling function used to determine the elevation of the grid cell, only supports 'linear'. + * Scaling function used to determine the elevation of the grid cell. + * Supported Values are 'linear' and 'quantile'. + * @default 'linear' */ - // elevationScaleType?: 'linear'; + elevationScaleType?: 'linear' | 'quantile'; /** * Material settings for lighting effect. Applies if `extruded: true`. @@ -272,35 +272,27 @@ export default class GridLayer extends BinOptions & { // Needed if getColorValue, getElevationValue are used dataAsArray?: DataT[]; + + colors?: AttributeWithScale; + elevations?: AttributeWithScale; + binIdRange: [number, number][]; aggregatorViewport: Viewport; }; getAggregatorType(): string { - const { - gpuAggregation, - gridAggregator, - // lowerPercentile, - // upperPercentile, - getColorValue, - getElevationValue - // colorScaleType - } = this.props; + const {gpuAggregation, gridAggregator, getColorValue, getElevationValue} = this.props; + if (gpuAggregation && (gridAggregator || getColorValue || getElevationValue)) { + // If these features are desired by the app, the user should explicitly use CPU aggregation + log.warn('Features not supported by GPU aggregation, falling back to CPU')(); + return 'cpu'; + } + if ( // GPU aggregation is requested gpuAggregation && // GPU aggregation is supported by the device - WebGLAggregator.isSupported(this.context.device) && - // Default grid - !gridAggregator && - // Does not need custom aggregation operation - !getColorValue && - !getElevationValue - // Does not need CPU-only scale - // && lowerPercentile === 0 && - // && upperPercentile === 100 && - // && colorScaleType !== 'quantile' - // && colorScaleType !== 'ordinal' + WebGLAggregator.isSupported(this.context.device) ) { return 'gpu'; } @@ -455,7 +447,7 @@ export default class GridLayer extends if (bounds && Number.isFinite(bounds[0][0])) { let centroid = [(bounds[0][0] + bounds[1][0]) / 2, (bounds[0][1] + bounds[1][1]) / 2]; const {cellSize} = this.props; - const {unitsPerMeter} = getDistanceScales({longitude: centroid[0], latitude: centroid[1]}); + const {unitsPerMeter} = viewport.getDistanceScales(centroid); cellSizeCommon[0] = unitsPerMeter[0] * cellSize; cellSizeCommon[1] = unitsPerMeter[1] * cellSize; @@ -511,8 +503,16 @@ export default class GridLayer extends const props = this.getCurrentLayer()!.props; const {aggregator} = this.state; if (channel === 0) { + const result = aggregator.getResult(0)!; + this.setState({ + colors: new AttributeWithScale(result, aggregator.binCount) + }); props.onSetColorDomain(aggregator.getResultDomain(0)); } else if (channel === 1) { + const result = aggregator.getResult(1)!; + this.setState({ + elevations: new AttributeWithScale(result, aggregator.binCount) + }); props.onSetElevationDomain(aggregator.getResultDomain(1)); } } @@ -550,12 +550,40 @@ export default class GridLayer extends renderLayers(): LayersList | Layer | null { const {aggregator, cellOriginCommon, cellSizeCommon} = this.state; - const {elevationScale, colorRange, elevationRange, extruded, coverage, material, transitions} = - this.props; + const { + elevationScale, + colorRange, + elevationRange, + extruded, + coverage, + material, + transitions, + colorScaleType, + lowerPercentile, + upperPercentile, + colorDomain, + elevationScaleType, + elevationLowerPercentile, + elevationUpperPercentile, + elevationDomain + } = this.props; const CellLayerClass = this.getSubLayerClass('cells', GridCellLayer); const binAttribute = aggregator.getBins(); - const colorsAttribute = aggregator.getResult(0); - const elevationsAttribute = aggregator.getResult(1); + + const colors = this.state.colors?.update({ + scaleType: colorScaleType, + lowerPercentile, + upperPercentile + }); + const elevations = this.state.elevations?.update({ + scaleType: elevationScaleType, + lowerPercentile: elevationLowerPercentile, + upperPercentile: elevationUpperPercentile + }); + + if (!colors || !elevations) { + return null; + } return new CellLayerClass( this.getSubLayerProps({ @@ -566,28 +594,30 @@ export default class GridLayer extends length: aggregator.binCount, attributes: { getBin: binAttribute, - getColorValue: colorsAttribute, - getElevationValue: elevationsAttribute + getColorValue: colors.attribute, + getElevationValue: elevations.attribute } }, // Data has changed shallowly, but we likely don't need to update the attributes dataComparator: (data, oldData) => data.length === oldData.length, updateTriggers: { getBin: [binAttribute], - getColorValue: [colorsAttribute], - getElevationValue: [elevationsAttribute] + getColorValue: [colors.attribute], + getElevationValue: [elevations.attribute] }, cellOriginCommon, cellSizeCommon, elevationScale, colorRange, + colorScaleType, elevationRange, extruded, coverage, material, - // Evaluate domain at draw() time - colorDomain: () => this.props.colorDomain || aggregator.getResultDomain(0), - elevationDomain: () => this.props.elevationDomain || aggregator.getResultDomain(1), + colorDomain: colors.domain || colorDomain || aggregator.getResultDomain(0), + elevationDomain: elevations.domain || elevationDomain || aggregator.getResultDomain(1), + colorCutoff: colors.cutoff, + elevationCutoff: elevations.cutoff, transitions: transitions && { getFillColor: transitions.getColorValue || transitions.getColorWeight, getElevation: transitions.getElevationValue || transitions.getElevationWeight diff --git a/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer-vertex.glsl.ts b/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer-vertex.glsl.ts index b82a10bfd3a..88bc07406c8 100644 --- a/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer-vertex.glsl.ts +++ b/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer-vertex.glsl.ts @@ -29,14 +29,19 @@ float interp(float value, vec2 domain, vec2 range) { } vec4 interp(float value, vec2 domain, sampler2D range) { - float r = min(max((value - domain.x) / (domain.y - domain.x), 0.), 1.); + float r = (value - domain.x) / (domain.y - domain.x); return texture(range, vec2(r, 0.5)); } void main(void) { geometry.pickingColor = instancePickingColors; - if (isnan(instanceColorValues)) { + if (isnan(instanceColorValues) || + instanceColorValues < hexagon.colorDomain.z || + instanceColorValues > hexagon.colorDomain.w || + instanceElevationValues < hexagon.elevationDomain.z || + instanceElevationValues > hexagon.elevationDomain.w + ) { gl_Position = vec4(0.); return; } @@ -49,7 +54,7 @@ void main(void) { // calculate z, if 3d not enabled set to 0 float elevation = 0.0; if (column.extruded) { - elevation = interp(instanceElevationValues, hexagon.elevationDomain, hexagon.elevationRange); + elevation = interp(instanceElevationValues, hexagon.elevationDomain.xy, hexagon.elevationRange); elevation = project_size(elevation); // cylindar gemoetry height are between -1.0 to 1.0, transform it to between 0, 1 geometry.position.z = (positions.z + 1.0) / 2.0 * elevation; @@ -58,7 +63,7 @@ void main(void) { gl_Position = project_common_position_to_clipspace(geometry.position); DECKGL_FILTER_GL_POSITION(gl_Position, geometry); - vColor = interp(instanceColorValues, hexagon.colorDomain, colorRange); + vColor = interp(instanceColorValues, hexagon.colorDomain.xy, colorRange); vColor.a *= layer.opacity; if (column.extruded) { vColor.rgb = lighting_getLightColor(vColor.rgb, project.cameraPosition, geometry.position.xyz, geometry.normal); diff --git a/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer.ts b/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer.ts index 0484c7a53d8..1d8b174b554 100644 --- a/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer.ts +++ b/modules/aggregation-layers/src/hexagon-layer/hexagon-cell-layer.ts @@ -5,16 +5,20 @@ import {Texture} from '@luma.gl/core'; import {UpdateParameters, Color} from '@deck.gl/core'; import {ColumnLayer} from '@deck.gl/layers'; -import {colorRangeToTexture} from '../common/utils/color-utils'; +import {createColorRangeTexture, updateColorRangeTexture} from '../common/utils/color-utils'; import vs from './hexagon-cell-layer-vertex.glsl'; import {HexagonProps, hexagonUniforms} from './hexagon-layer-uniforms'; +import type {ScaleType} from '../common/types'; /** Proprties added by HexagonCellLayer. */ export type _HexagonCellLayerProps = { hexOriginCommon: [number, number]; - colorDomain: () => [number, number]; - colorRange?: Color[]; - elevationDomain: () => [number, number]; + colorDomain: [number, number]; + colorCutoff: [number, number] | null; + colorRange: Color[]; + colorScaleType: ScaleType; + elevationDomain: [number, number]; + elevationCutoff: [number, number] | null; elevationRange: [number, number]; }; @@ -71,10 +75,15 @@ export default class HexagonCellLayer extends Colum if (oldProps.colorRange !== props.colorRange) { this.state.colorTexture?.destroy(); - this.state.colorTexture = colorRangeToTexture(this.context.device, props.colorRange); - + this.state.colorTexture = createColorRangeTexture( + this.context.device, + props.colorRange, + props.colorScaleType + ); const hexagonProps: Partial = {colorRange: this.state.colorTexture}; model.shaderInputs.setProps({hexagon: hexagonProps}); + } else if (oldProps.colorScaleType !== props.colorScaleType) { + updateColorRangeTexture(this.state.colorTexture, props.colorScaleType); } } @@ -85,11 +94,18 @@ export default class HexagonCellLayer extends Colum } draw({uniforms}) { - // Use dynamic domain from the aggregator - const colorDomain = this.props.colorDomain(); - const elevationDomain = this.props.elevationDomain(); - const {radius, hexOriginCommon, elevationRange, elevationScale, extruded, coverage} = - this.props; + const { + radius, + hexOriginCommon, + elevationRange, + elevationScale, + extruded, + coverage, + colorDomain, + elevationDomain + } = this.props; + const colorCutoff = this.props.colorCutoff || [-Infinity, Infinity]; + const elevationCutoff = this.props.elevationCutoff || [-Infinity, Infinity]; const fillModel = this.state.fillModel!; if (fillModel.vertexArray.indexBuffer) { @@ -100,8 +116,18 @@ export default class HexagonCellLayer extends Colum fillModel.setVertexCount(this.state.fillVertexCount); const hexagonProps: Omit = { - colorDomain, - elevationDomain, + colorDomain: [ + Math.max(colorDomain[0], colorCutoff[0]), // instanceColorValue that maps to colorRange[0] + Math.min(colorDomain[1], colorCutoff[1]), // instanceColorValue that maps to colorRange[colorRange.length - 1] + Math.max(colorDomain[0] - 1, colorCutoff[0]), // hide cell if instanceColorValue is less than this + Math.min(colorDomain[1] + 1, colorCutoff[1]) // hide cell if instanceColorValue is greater than this + ], + elevationDomain: [ + Math.max(elevationDomain[0], elevationCutoff[0]), // instanceElevationValue that maps to elevationRange[0] + Math.min(elevationDomain[1], elevationCutoff[1]), // instanceElevationValue that maps to elevationRange[elevationRange.length - 1] + Math.max(elevationDomain[0] - 1, elevationCutoff[0]), // hide cell if instanceElevationValue is less than this + Math.min(elevationDomain[1] + 1, elevationCutoff[1]) // hide cell if instanceElevationValue is greater than this + ], elevationRange: [elevationRange[0] * elevationScale, elevationRange[1] * elevationScale], originCommon: hexOriginCommon }; diff --git a/modules/aggregation-layers/src/hexagon-layer/hexagon-layer-uniforms.ts b/modules/aggregation-layers/src/hexagon-layer/hexagon-layer-uniforms.ts index 8c64e1919ff..24fcb489831 100644 --- a/modules/aggregation-layers/src/hexagon-layer/hexagon-layer-uniforms.ts +++ b/modules/aggregation-layers/src/hexagon-layer/hexagon-layer-uniforms.ts @@ -3,17 +3,17 @@ import type {ShaderModule} from '@luma.gl/shadertools'; const uniformBlock = /* glsl */ `\ uniform hexagonUniforms { - vec2 colorDomain; - vec2 elevationDomain; + vec4 colorDomain; + vec4 elevationDomain; vec2 elevationRange; vec2 originCommon; } hexagon; `; export type HexagonProps = { - colorDomain: [number, number]; + colorDomain: [number, number, number, number]; colorRange: Texture; - elevationDomain: [number, number]; + elevationDomain: [number, number, number, number]; elevationRange: [number, number]; originCommon: [number, number]; }; @@ -22,8 +22,8 @@ export const hexagonUniforms = { name: 'hexagon', vs: uniformBlock, uniformTypes: { - colorDomain: 'vec2', - elevationDomain: 'vec2', + colorDomain: 'vec4', + elevationDomain: 'vec4', elevationRange: 'vec2', originCommon: 'vec2' } diff --git a/modules/aggregation-layers/src/hexagon-layer/hexagon-layer.ts b/modules/aggregation-layers/src/hexagon-layer/hexagon-layer.ts index bf88d104e96..d26bcad39e3 100644 --- a/modules/aggregation-layers/src/hexagon-layer/hexagon-layer.ts +++ b/modules/aggregation-layers/src/hexagon-layer/hexagon-layer.ts @@ -3,6 +3,7 @@ // Copyright (c) vis.gl contributors import { + log, Accessor, Color, GetPickingInfoParams, @@ -18,11 +19,11 @@ import { UpdateParameters, DefaultProps } from '@deck.gl/core'; -import {getDistanceScales} from '@math.gl/web-mercator'; import {WebGLAggregator, CPUAggregator, AggregationOperation} from '../common/aggregator/index'; import AggregationLayer from '../common/aggregation-layer'; import {AggregateAccessor} from '../common/types'; import {defaultColorRange} from '../common/utils/color-utils'; +import {AttributeWithScale} from '../common/utils/scale-utils'; import HexagonCellLayer from './hexagon-cell-layer'; import {pointToHexbin, HexbinVertices, getHexbinCentroid, pointToHexbinGLSL} from './hexbin'; @@ -40,9 +41,9 @@ const defaultProps: DefaultProps = { getColorValue: {type: 'accessor', value: null}, // default value is calculated from `getColorWeight` and `colorAggregation` getColorWeight: {type: 'accessor', value: 1}, colorAggregation: 'SUM', - // lowerPercentile: {type: 'number', min: 0, max: 100, value: 0}, - // upperPercentile: {type: 'number', min: 0, max: 100, value: 100}, - // colorScaleType: 'quantize', + lowerPercentile: {type: 'number', min: 0, max: 100, value: 0}, + upperPercentile: {type: 'number', min: 0, max: 100, value: 100}, + colorScaleType: 'quantize', onSetColorDomain: noop, // elevation @@ -52,9 +53,9 @@ const defaultProps: DefaultProps = { getElevationWeight: {type: 'accessor', value: 1}, elevationAggregation: 'SUM', elevationScale: {type: 'number', min: 0, value: 1}, - // elevationLowerPercentile: {type: 'number', min: 0, max: 100, value: 0}, - // elevationUpperPercentile: {type: 'number', min: 0, max: 100, value: 100}, - // elevationScaleType: 'linear', + elevationLowerPercentile: {type: 'number', min: 0, max: 100, value: 0}, + elevationUpperPercentile: {type: 'number', min: 0, max: 100, value: 100}, + elevationScaleType: 'linear', onSetElevationDomain: noop, // hexbin @@ -80,7 +81,8 @@ type _HexagonLayerProps = { radius?: number; /** - * Accessor to retrieve a hexagonal bin index from each data object. + * Custom accessor to retrieve a hexagonal bin index from each data object. + * Not supported by GPU aggregation. * @default d3-hexbin */ hexagonAggregator?: ((position: number[], radius: number) => [number, number]) | null; @@ -126,21 +128,19 @@ type _HexagonLayerProps = { */ extruded?: boolean; - // TODO - v9 /** * Filter cells and re-calculate color by `upperPercentile`. * Cells with value larger than the upperPercentile will be hidden. * @default 100 */ - // upperPercentile?: number; + upperPercentile?: number; - // TODO - v9 /** * Filter cells and re-calculate color by `lowerPercentile`. * Cells with value smaller than the lowerPercentile will be hidden. * @default 0 */ - // lowerPercentile?: number; + lowerPercentile?: number; /** * Filter cells and re-calculate elevation by `elevationUpperPercentile`. @@ -156,19 +156,19 @@ type _HexagonLayerProps = { */ elevationLowerPercentile?: number; - // TODO - v9 /** * Scaling function used to determine the color of the grid cell, default value is 'quantize'. * Supported Values are 'quantize', 'linear', 'quantile' and 'ordinal'. * @default 'quantize' */ - // colorScaleType?: 'quantize' | 'linear' | 'quantile' | 'ordinal'; + colorScaleType?: 'quantize' | 'linear' | 'quantile' | 'ordinal'; - // TODO - v9 /** * Scaling function used to determine the elevation of the grid cell, only supports 'linear'. + * Supported Values are 'linear' and 'quantile'. + * @default 'linear' */ - // elevationScaleType?: 'linear'; + elevationScaleType?: 'linear'; /** * Material settings for lighting effect. Applies if `extruded: true`. @@ -276,37 +276,27 @@ export default class HexagonLayer< BinOptions & { // Needed if getColorValue, getElevationValue are used dataAsArray?: DataT[]; - radiusCommon: number; - hexOriginCommon: [number, number]; + + colors?: AttributeWithScale; + elevations?: AttributeWithScale; + binIdRange: [number, number][]; aggregatorViewport: Viewport; }; getAggregatorType(): string { - const { - gpuAggregation, - hexagonAggregator, - // lowerPercentile, - // upperPercentile, - getColorValue, - getElevationValue - // colorScaleType - } = this.props; + const {gpuAggregation, hexagonAggregator, getColorValue, getElevationValue} = this.props; + if (gpuAggregation && (hexagonAggregator || getColorValue || getElevationValue)) { + // If these features are desired by the app, the user should explicitly use CPU aggregation + log.warn('Features not supported by GPU aggregation, falling back to CPU')(); + return 'cpu'; + } + if ( // GPU aggregation is requested gpuAggregation && // GPU aggregation is supported by the device - WebGLAggregator.isSupported(this.context.device) && - // Default hexbin - !hexagonAggregator && - // Does not need custom aggregation operation - !getColorValue && - !getElevationValue - // Does not need CPU-only scale - // && lowerPercentile === 0 && - // && upperPercentile === 100 && - // && colorScaleType !== 'quantile' - // && colorScaleType !== 'ordinal' + WebGLAggregator.isSupported(this.context.device) ) { return 'gpu'; } @@ -462,7 +452,7 @@ export default class HexagonLayer< if (bounds && Number.isFinite(bounds[0][0])) { let centroid = [(bounds[0][0] + bounds[1][0]) / 2, (bounds[0][1] + bounds[1][1]) / 2]; const {radius} = this.props; - const {unitsPerMeter} = getDistanceScales({longitude: centroid[0], latitude: centroid[1]}); + const {unitsPerMeter} = viewport.getDistanceScales(centroid); radiusCommon = unitsPerMeter[0] * radius; // Use the centroid of the hex at the center of the data @@ -516,8 +506,16 @@ export default class HexagonLayer< const props = this.getCurrentLayer()!.props; const {aggregator} = this.state; if (channel === 0) { + const result = aggregator.getResult(0)!; + this.setState({ + colors: new AttributeWithScale(result, aggregator.binCount) + }); props.onSetColorDomain(aggregator.getResultDomain(0)); } else if (channel === 1) { + const result = aggregator.getResult(1)!; + this.setState({ + elevations: new AttributeWithScale(result, aggregator.binCount) + }); props.onSetElevationDomain(aggregator.getResultDomain(1)); } } @@ -555,12 +553,40 @@ export default class HexagonLayer< renderLayers(): LayersList | Layer | null { const {aggregator, radiusCommon, hexOriginCommon} = this.state; - const {elevationScale, colorRange, elevationRange, extruded, coverage, material, transitions} = - this.props; + const { + elevationScale, + colorRange, + elevationRange, + extruded, + coverage, + material, + transitions, + colorScaleType, + lowerPercentile, + upperPercentile, + colorDomain, + elevationScaleType, + elevationLowerPercentile, + elevationUpperPercentile, + elevationDomain + } = this.props; const CellLayerClass = this.getSubLayerClass('cells', HexagonCellLayer); const binAttribute = aggregator.getBins(); - const colorsAttribute = aggregator.getResult(0); - const elevationsAttribute = aggregator.getResult(1); + + const colors = this.state.colors?.update({ + scaleType: colorScaleType, + lowerPercentile, + upperPercentile + }); + const elevations = this.state.elevations?.update({ + scaleType: elevationScaleType, + lowerPercentile: elevationLowerPercentile, + upperPercentile: elevationUpperPercentile + }); + + if (!colors || !elevations) { + return null; + } return new CellLayerClass( this.getSubLayerProps({ @@ -571,16 +597,16 @@ export default class HexagonLayer< length: aggregator.binCount, attributes: { getBin: binAttribute, - getColorValue: colorsAttribute, - getElevationValue: elevationsAttribute + getColorValue: colors.attribute, + getElevationValue: elevations.attribute } }, // Data has changed shallowly, but we likely don't need to update the attributes dataComparator: (data, oldData) => data.length === oldData.length, updateTriggers: { getBin: [binAttribute], - getColorValue: [colorsAttribute], - getElevationValue: [elevationsAttribute] + getColorValue: [colors.attribute], + getElevationValue: [elevations.attribute] }, diskResolution: 6, vertices: HexbinVertices, @@ -588,13 +614,15 @@ export default class HexagonLayer< hexOriginCommon, elevationScale, colorRange, + colorScaleType, elevationRange, extruded, coverage, material, - // Evaluate domain at draw() time - colorDomain: () => this.props.colorDomain || aggregator.getResultDomain(0), - elevationDomain: () => this.props.elevationDomain || aggregator.getResultDomain(1), + colorDomain: colors.domain || colorDomain || aggregator.getResultDomain(0), + elevationDomain: elevations.domain || elevationDomain || aggregator.getResultDomain(1), + colorCutoff: colors.cutoff, + elevationCutoff: elevations.cutoff, transitions: transitions && { getFillColor: transitions.getColorValue || transitions.getColorWeight, getElevation: transitions.getElevationValue || transitions.getElevationWeight diff --git a/modules/aggregation-layers/src/screen-grid-layer/screen-grid-cell-layer.ts b/modules/aggregation-layers/src/screen-grid-layer/screen-grid-cell-layer.ts index 5669605b29e..dc77bfe7ef9 100644 --- a/modules/aggregation-layers/src/screen-grid-layer/screen-grid-cell-layer.ts +++ b/modules/aggregation-layers/src/screen-grid-layer/screen-grid-cell-layer.ts @@ -21,7 +21,7 @@ import {Texture} from '@luma.gl/core'; import {Model, Geometry} from '@luma.gl/engine'; import {Layer, picking, UpdateParameters, DefaultProps, Color} from '@deck.gl/core'; -import {defaultColorRange, colorRangeToTexture} from '../common/utils/color-utils'; +import {defaultColorRange, createColorRangeTexture} from '../common/utils/color-utils'; import vs from './screen-grid-layer-vertex.glsl'; import fs from './screen-grid-layer-fragment.glsl'; import {ScreenGridProps, screenGridUniforms} from './screen-grid-layer-uniforms'; @@ -81,7 +81,7 @@ export default class ScreenGridCellLayer extends La if (oldProps.colorRange !== props.colorRange) { this.state.colorTexture?.destroy(); - this.state.colorTexture = colorRangeToTexture(this.context.device, props.colorRange); + this.state.colorTexture = createColorRangeTexture(this.context.device, props.colorRange); const screenGridProps: Partial = {colorRange: this.state.colorTexture}; model.shaderInputs.setProps({screenGrid: screenGridProps}); } diff --git a/test/modules/aggregation-layers/common/utils/scale-utils.spec.ts b/test/modules/aggregation-layers/common/utils/scale-utils.spec.ts index 4294af1efda..f524aedb144 100644 --- a/test/modules/aggregation-layers/common/utils/scale-utils.spec.ts +++ b/test/modules/aggregation-layers/common/utils/scale-utils.spec.ts @@ -1,161 +1,257 @@ import test from 'tape-promise/tape'; import { - quantizeScale, - getQuantileScale, - getOrdinalScale, - getLinearScale + AttributeWithScale, + applyScaleQuantile, + applyScaleOrdinal } from '@deck.gl/aggregation-layers/common/utils/scale-utils'; +import {device} from '@deck.gl/test-utils'; -const RANGE = [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]; -const LINEAR_SCALE_TEST_CASES = [ +const QUANTILE_SCALE_TEST_CASES = [ { - title: 'multi-value-domain', - domain: [1, 10], - range: [2, 20], - value: 5, - result: 10 - } -]; - -const QUANTIZE_SCALE_TEST_CASES = [ + title: 'multi-values', + rangeSize: 4, + values: [1, 3, 6, 6.9, 7, 7.1, 8, 8.9, 9, 9.1, 10, 13, 14.9, 15, 15.1, 16], + results: [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3] + }, { - title: 'multi-value-domain', - domain: [1, 10], - range: RANGE, - value: 5, - result: 500 + title: 'multi-values-2', + rangeSize: 8, + values: [1, 3, 6, 6.9, 7, 7.1, 8, 8.9, 9, 9.1, 10, 13, 14.9, 15, 15.1, 16], + results: [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7] }, { - title: 'single-value-domain', - domain: [1, 1], - range: RANGE, - value: 1, - result: RANGE[0] + title: 'unsorted', + rangeSize: 4, + values: [13, 7, 6, 3, 8.9, 1, 9.1, 8, 16, 7.1, 10, 15.1, 15, 14.9, 9, 6.9], + results: [2, 1, 0, 0, 1, 0, 2, 1, 3, 1, 2, 3, 3, 3, 2, 0] }, { - title: 'negative-value-domain', - domain: [10, 1], - range: RANGE, - value: 1, - result: RANGE[0] + title: 'single-value', + rangeSize: 4, + values: new Array(20).fill(0), + results: new Array(20).fill(3) + }, + { + title: 'with-NaN', + rangeSize: 4, + values: [NaN, NaN, 0, NaN, 6, 3, NaN, 3, NaN, 2, 0], + results: [NaN, NaN, 0, NaN, 3, 3, NaN, 3, NaN, 1, 0] } ]; -const QUANTILE_SCALE_TEST_CASES = [ - { - title: 'multi-value-domain', - domain: [3, 6, 7, 8, 8, 10, 13, 15, 16, 20], - range: [11, 22, 33, 44], - values: [1, 3, 6, 6.9, 7, 7.1, 8, 8.9, 9, 9.1, 10, 13, 14.9, 15, 15.1, 16, 20, 100], - results: [11, 11, 11, 11, 11, 11, 22, 22, 33, 33, 33, 33, 44, 44, 44, 44, 44, 44] - }, +const ORDINAL_SCALE_TEST_CASES = [ { - title: 'unsorted-domain', - domain: [8, 16, 15, 3, 6, 7, 8, 20, 10, 13], - range: [11, 22, 33, 44], - values: [1, 3, 6, 6.9, 7, 7.1, 8, 8.9, 9, 9.1, 10, 13, 14.9, 15, 15.1, 16, 20, 100], - results: [11, 11, 11, 11, 11, 11, 22, 22, 33, 33, 33, 33, 44, 44, 44, 44, 44, 44] + title: 'unique-values', + values: [0.5, 1, 3, 3, 3], + results: [0, 1, 2, 2, 2] }, { - title: 'single-value-domain', - domain: [8], - range: [11, 22, 33, 44], - values: [1, 3, 6, 6.9, 7, 7.1, 8, 8.9, 9, 9.1, 10, 13, 14.9, 15, 15.1, 16, 20, 100], - results: [11, 11, 11, 11, 11, 11, 44, 44, 44, 44, 44, 44, 44, 44, 44, 44, 44, 44] + title: 'unsorted', + values: [3, 0.5, 1, 3, 0.5], + results: [2, 0, 1, 2, 0] }, { - title: 'single-value-range', - domain: [3, 6, 7, 8, 8, 10, 13, 15, 16, 20], - range: [11], - values: [1, 3, 6, 6.9, 7, 7.1, 8, 8.9, 9, 9.1, 10, 13, 14.9, 15, 15.1, 16, 20, 44], - result: 11 + title: 'with-NaN', + values: [NaN, NaN, 1, NaN, 0.5, NaN, 3], + results: [NaN, NaN, 1, NaN, 0, NaN, 2] } ]; -const ORDINAL_SCALE_TEST_CASES = [ - { - title: 'uniquely-maps-domain-to-range', - domain: [0, 1], - range: [11, 22], - values: [0, 1], - results: [11, 22] - }, +const ATTRIBUTE_TEST_CASES = [ { - title: 'string-value-domain', - domain: ['0', '1'], - range: [11, 22], - values: [0, '0', 1, '1'], - results: [11, 11, 22, 22] + title: 'sequence-value', + input: { + value: new Float32Array(Array.from({length: 101}, (_, i) => i * 10)), + offset: 0, + stride: 4 + }, + length: 101, + testCases: [ + { + props: { + scaleType: 'linear', + lowerPercentile: 0, + upperPercentile: 100 + }, + expected: { + domain: null, + cutoff: null + } + }, + { + props: { + scaleType: 'linear', + lowerPercentile: 0, + upperPercentile: 90 + }, + expected: { + domain: null, + cutoff: [-Infinity, 900] + } + }, + { + props: { + scaleType: 'linear', + lowerPercentile: 10, + upperPercentile: 100 + }, + expected: { + domain: null, + cutoff: [100, Infinity] + } + }, + { + props: { + scaleType: 'quantile', + lowerPercentile: 10, + upperPercentile: 100 + }, + expected: { + domain: [0, 99], + cutoff: [10, 99] + } + } + ] }, { - title: 'extends-domain', - domain: [0, 1], - range: [11, 22, 33], - values: [0, 1, 2], - results: [11, 22, 33] + title: 'sparse-value', + input: { + value: new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 2]), + offset: 0, + stride: 4 + }, + length: 10, + testCases: [ + { + props: { + scaleType: 'quantize', + lowerPercentile: 0, + upperPercentile: 100 + }, + expected: { + domain: null, + cutoff: null + } + }, + { + props: { + scaleType: 'quantize', + lowerPercentile: 20, + upperPercentile: 80 + }, + expected: { + domain: null, + cutoff: [1, 1] + } + }, + { + props: { + scaleType: 'ordinal', + lowerPercentile: 0, + upperPercentile: 80 + }, + expected: { + domain: [0, 1], + cutoff: [0, 0] + } + }, + { + props: { + scaleType: 'ordinal', + lowerPercentile: 20, + upperPercentile: 100 + }, + expected: { + domain: [0, 1], + cutoff: [0, 1] + } + } + ] }, { - title: 'recycles values', - domain: [0, 1], - range: [11, 22, 33], - values: [0, 1, 2, 3, 4, 5, 6], - results: [11, 22, 33, 11, 22, 33, 11] + title: 'interleaved', + input: { + value: new Float32Array(new Array(101).fill(0).flatMap((_, i) => [Math.random(), i * 10, 1])), + offset: 4, + stride: 12 + }, + length: 101, + testCases: [ + { + props: { + scaleType: 'linear', + lowerPercentile: 10, + upperPercentile: 90 + }, + expected: { + domain: null, + cutoff: [100, 900] + } + } + ] } ]; -test('scale-utils#import', t => { - t.ok(quantizeScale, 'quantizeScale imported OK'); - t.end(); -}); - -test('scale-utils@linearScale', t => { - for (const tc of LINEAR_SCALE_TEST_CASES) { - const linearScale = getLinearScale(tc.domain, tc.range); - const result = linearScale(tc.value); - t.deepEqual(result, tc.result, `quantizeScale ${tc.title} returned expected value`); +test('scale-utils#quantileScale', t => { + for (const tc of QUANTILE_SCALE_TEST_CASES) { + const output = applyScaleQuantile(new Float32Array(tc.values), tc.rangeSize); + t.deepEqual( + output.attribute.value, + tc.results, + `applyScaleQuantile ${tc.title} returned expected value` + ); } t.end(); }); -test('scale-utils#quantizeScale', t => { - for (const tc of QUANTIZE_SCALE_TEST_CASES) { - const result = quantizeScale(tc.domain, tc.range, tc.value); - t.deepEqual(result, tc.result, `quantizeScale ${tc.title} returned expected value`); +test('scale-utils#ordinalScale', t => { + for (const tc of ORDINAL_SCALE_TEST_CASES) { + const output = applyScaleOrdinal(new Float32Array(tc.values)); + t.deepEqual( + output.attribute.value, + tc.results, + `applyScaleOrdinal ${tc.title} returned expected value` + ); } t.end(); }); -test('scale-utils#quantileScale', t => { - for (const tc of QUANTILE_SCALE_TEST_CASES) { - const quantileScale = getQuantileScale(tc.domain, tc.range); - t.deepEqual( - quantileScale.domain(), - tc.domain, - `quantileScale.domain() ${tc.title} returned expected value` - ); - for (const i in tc.values) { - const result = quantileScale(tc.values[i]); - t.deepEqual( - result, - tc.results ? tc.results[i] : tc.result, - `quantileScale ${tc.title} returned expected value` - ); +test('AttributeWithScale#CPU#update', t => { + for (const {title, input, length, testCases} of ATTRIBUTE_TEST_CASES) { + const a = new AttributeWithScale(input, length); + for (const testCase of testCases) { + a.update(testCase.props); + for (const key in testCase.expected) { + t.deepEqual( + a[key], + testCase.expected[key], + `${title} ${testCase.props.scaleType} returns expected ${key}` + ); + } } } t.end(); }); -test('scale-utils#ordinalScale', t => { - for (const tc of ORDINAL_SCALE_TEST_CASES) { - const ordinalScale = getOrdinalScale(tc.domain, tc.range); - t.deepEqual( - ordinalScale.domain(), - tc.domain, - `ordinalScale.domain() ${tc.title} returned expected value` - ); - for (const i in tc.values) { - const result = ordinalScale(tc.values[i]); - t.deepEqual(result, tc.results[i], `ordinalScale ${tc.title} returned expected value`); +test('AttributeWithScale#GPU#update', t => { + for (const {title, input, length, testCases} of ATTRIBUTE_TEST_CASES) { + // Simulate a binary attribute with only GPU buffer + const gpuInput = { + ...input, + value: undefined, + buffer: device.createBuffer({data: input.value}) + }; + + const a = new AttributeWithScale(gpuInput, length); + for (const testCase of testCases) { + a.update(testCase.props); + for (const key in testCase.expected) { + t.deepEqual( + a[key], + testCase.expected[key], + `${title} ${testCase.props.scaleType} returns expected ${key}` + ); + } } } t.end(); diff --git a/test/modules/aggregation-layers/grid-layer.spec.ts b/test/modules/aggregation-layers/grid-layer.spec.ts index aec6d295b23..dcd8448d68c 100644 --- a/test/modules/aggregation-layers/grid-layer.spec.ts +++ b/test/modules/aggregation-layers/grid-layer.spec.ts @@ -78,29 +78,6 @@ test('GridLayer#getAggregatorType', t => { ); } }, - // v9 TODO - enable after implementing upperPercentile - // { - // updateProps: { - // upperPercentile: 90 - // }, - // onAfterUpdate({layer, subLayers, spies}) { - // t.ok( - // layer.state.useGPUAggregation === false, - // 'Should use CPU Aggregation (upperPercentile: 90)' - // ); - // } - // }, - // { - // updateProps: { - // upperPercentile: 100 - // }, - // onAfterUpdate({layer, subLayers, spies}) { - // t.ok( - // layer.state.useGPUAggregation === true, - // 'Should use GPU Aggregation (upperPercentile: 100)' - // ); - // } - // }, { title: 'fallback to CPU aggregation', updateProps: { @@ -125,29 +102,6 @@ test('GridLayer#getAggregatorType', t => { ); } } - // v9 TODO - enable after implementing colorScaleType - // { - // updateProps: { - // colorScaleType: 'quantile' - // }, - // onAfterUpdate({layer, subLayers, spies}) { - // t.ok( - // layer.state.useGPUAggregation === false, - // "Should use CPU Aggregation (colorScaleType: 'quantile')" - // ); - // } - // }, - // { - // updateProps: { - // colorScaleType: 'ordinal' - // }, - // onAfterUpdate({layer, subLayers, spies}) { - // t.ok( - // layer.state.useGPUAggregation === false, - // "Should use CPU Aggregation (colorScaleType: 'ordinal')" - // ); - // } - // } ] }); t.end(); diff --git a/test/modules/aggregation-layers/hexagon-layer.spec.ts b/test/modules/aggregation-layers/hexagon-layer.spec.ts index eac9fc3ba85..dc91c8fbf41 100644 --- a/test/modules/aggregation-layers/hexagon-layer.spec.ts +++ b/test/modules/aggregation-layers/hexagon-layer.spec.ts @@ -62,28 +62,6 @@ test('HexagonLayer#getAggregatorType', t => { ); } }, - // { - // updateProps: { - // upperPercentile: 90 - // }, - // onAfterUpdate({layer, subLayers, spies}) { - // t.ok( - // layer.state.useGPUAggregation === false, - // 'Should use CPU Aggregation (upperPercentile: 90)' - // ); - // } - // }, - // { - // updateProps: { - // upperPercentile: 100 - // }, - // onAfterUpdate({layer, subLayers, spies}) { - // t.ok( - // layer.state.useGPUAggregation === true, - // 'Should use GPU Aggregation (upperPercentile: 100)' - // ); - // } - // }, { title: 'fallback to CPU aggregation', updateProps: { @@ -108,28 +86,6 @@ test('HexagonLayer#getAggregatorType', t => { ); } } - // { - // updateProps: { - // colorScaleType: 'quantile' - // }, - // onAfterUpdate({layer, subLayers, spies}) { - // t.ok( - // layer.state.useGPUAggregation === false, - // "Should use CPU Aggregation (colorScaleType: 'quantile')" - // ); - // } - // }, - // { - // updateProps: { - // colorScaleType: 'ordinal' - // }, - // onAfterUpdate({layer, subLayers, spies}) { - // t.ok( - // layer.state.useGPUAggregation === false, - // "Should use CPU Aggregation (colorScaleType: 'ordinal')" - // ); - // } - // } ] }); t.end(); diff --git a/test/modules/core/lib/pick-layers.spec.ts b/test/modules/core/lib/pick-layers.spec.ts index 69bd3c8c3a2..79123726ab3 100644 --- a/test/modules/core/lib/pick-layers.spec.ts +++ b/test/modules/core/lib/pick-layers.spec.ts @@ -823,7 +823,12 @@ function updateDeckProps(deck: Deck, props: DeckProps): Promise { deck.setProps({ ...DECK_PROPS, ...props, - onAfterRender: () => resolve() + onAfterRender: () => { + // @ts-expect-error private member + if (!deck.layerManager.needsUpdate()) { + resolve(); + } + } }); }); } diff --git a/test/render/golden-images/cpu-layer-ordinal.png b/test/render/golden-images/cpu-layer-ordinal.png index 951719985f2..0b42a5d18d7 100644 Binary files a/test/render/golden-images/cpu-layer-ordinal.png and b/test/render/golden-images/cpu-layer-ordinal.png differ diff --git a/test/render/golden-images/cpu-layer-quantile.png b/test/render/golden-images/cpu-layer-quantile.png index 7781c50f34c..7ea565d9090 100644 Binary files a/test/render/golden-images/cpu-layer-quantile.png and b/test/render/golden-images/cpu-layer-quantile.png differ diff --git a/test/render/test-cases/grid-layer.js b/test/render/test-cases/grid-layer.js index 236062f35ac..f7918198c8b 100644 --- a/test/render/test-cases/grid-layer.js +++ b/test/render/test-cases/grid-layer.js @@ -44,37 +44,36 @@ export function getMax(pts, key) { } export default [ - // v9 TODO - enable after implementing colorScaleType - // { - // name: 'cpu-grid-layer:quantile', - // viewState: VIEW_STATE, - // layers: [ - // new CPUGridLayer( - // Object.assign({}, PROPS, { - // id: 'cpu-grid-layer:quantile', - // getColorValue: points => getMean(points, 'SPACES'), - // getElevationValue: points => getMax(points, 'SPACES'), - // colorScaleType: 'quantile' - // }) - // ) - // ], - // goldenImage: './test/render/golden-images/cpu-layer-quantile.png' - // }, - // { - // name: 'cpu-grid-layer:ordinal', - // viewState: VIEW_STATE, - // layers: [ - // new CPUGridLayer( - // Object.assign({}, PROPS, { - // id: 'cpu-grid-layer:ordinal', - // getColorValue: points => getMean(points, 'SPACES'), - // getElevationValue: points => getMax(points, 'SPACES'), - // colorScaleType: 'ordinal' - // }) - // ) - // ], - // goldenImage: './test/render/golden-images/cpu-layer-ordinal.png' - // }, + { + name: 'cpu-grid-layer:quantile', + viewState: VIEW_STATE, + layers: [ + new GridLayer({ + ...PROPS, + gpuAggregation: false, + id: 'cpu-grid-layer:quantile', + getColorValue: points => getMean(points, 'SPACES'), + getElevationValue: points => getMax(points, 'SPACES'), + colorScaleType: 'quantile' + }) + ], + goldenImage: './test/render/golden-images/cpu-layer-quantile.png' + }, + { + name: 'cpu-grid-layer:ordinal', + viewState: VIEW_STATE, + layers: [ + new GridLayer({ + ...PROPS, + gpuAggregation: false, + id: 'cpu-grid-layer:ordinal', + getColorValue: points => getMean(points, 'SPACES'), + getElevationValue: points => getMax(points, 'SPACES'), + colorScaleType: 'ordinal' + }) + ], + goldenImage: './test/render/golden-images/cpu-layer-ordinal.png' + }, { name: 'grid-layer#cpu:value-accessors', viewState: VIEW_STATE,