From f9440774a79c273dad98770ce32d5d103adcaf68 Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Tue, 3 Sep 2024 19:37:12 -0700 Subject: [PATCH] GPU Aggregation: Support *ScaleType and *Percentile props (#9130) --- .../aggregation-layers/src/common/types.ts | 2 + .../src/common/utils/color-utils.ts | 25 +- .../src/common/utils/scale-utils.js | 219 ------------ .../src/common/utils/scale-utils.ts | 232 +++++++++++++ .../grid-layer/grid-cell-layer-vertex.glsl.ts | 13 +- .../src/grid-layer/grid-cell-layer.ts | 52 ++- .../src/grid-layer/grid-layer-uniforms.ts | 12 +- .../src/grid-layer/grid-layer.ts | 130 ++++--- .../hexagon-cell-layer-vertex.glsl.ts | 13 +- .../src/hexagon-layer/hexagon-cell-layer.ts | 52 ++- .../hexagon-layer/hexagon-layer-uniforms.ts | 12 +- .../src/hexagon-layer/hexagon-layer.ts | 128 ++++--- .../screen-grid-cell-layer.ts | 4 +- .../common/utils/scale-utils.spec.ts | 328 +++++++++++------- .../aggregation-layers/grid-layer.spec.ts | 46 --- .../aggregation-layers/hexagon-layer.spec.ts | 44 --- test/modules/core/lib/pick-layers.spec.ts | 7 +- .../golden-images/cpu-layer-ordinal.png | Bin 19205 -> 15739 bytes .../golden-images/cpu-layer-quantile.png | Bin 17853 -> 14951 bytes test/render/test-cases/grid-layer.js | 61 ++-- 20 files changed, 772 insertions(+), 608 deletions(-) delete mode 100644 modules/aggregation-layers/src/common/utils/scale-utils.js create mode 100644 modules/aggregation-layers/src/common/utils/scale-utils.ts 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 951719985f26d06d2d9f9ba883469c320e0da2a8..0b42a5d18d7781b3ed3334ccb913e09d95fd03a1 100644 GIT binary patch literal 15739 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

c zU1!Ygu=dq%tKM{Frm!iqkCGl>dGl{Qu)q2^e-F_X$!y6$$lR%}_~2GTaDxW^-432}ZHaCT35)EMXJ5=)@fXTh8Sg@hB-%7O2z2H6 zuu}#rhtBJOQtf!=^Zt@tdkENi5nEp7mX$7E{ylhmPf%8a%q*KZ=KFB?gsv25HTZ1kMpXV-^0NU{bI=yf$T9| z)JZaf*bnL5{HjC0s@eoggPtzDt{3m>Vp^pRlU^~(UvPJb$SxRvy!vI{z%#%T=?>b% z*bPBbeqH7$o)29;pVWtt<1V0LI07-dAnbH$y%b%mzfe;xv7l9Ez1Bu9P+cHA94CIuFtWCtk+QbNh7LC0bx?=PKzb?v*iIX2?5USYj8t;bY`lk59M{a z4>rr-wQMbM*}qN>tZ$L}oD?@`<{b=@9@gFP1X?ZT^vg)q^=B>=6QUP+@JbN4t~wNO zrU}EQKwV@UkZB2<{Potws`T54Din@u7$wxi8}09W%X9sLe_DToSi8fh>y&T^-xv7h zF+WazLMf)V$?daLE&o)pe*7{h9y;ZL--YSE!8@7FB+cm6#e?`=q6GmG z@QPJLgIm1;$IFh*_$>0{{Ni=8qL~`zT46-Mpzm-Hi%jt(=3nIww)vCH`?;I z4;VqG%3~TdY;|~_*ZLs$KNyC3JBg~JMq+8u+4%cCFWVnz5HO-MB=l#|5MF+Izf#oJ zHN%7%b$;^r{56H+#VF`9FR6`@~8Nq7pujPkdr@UpNa>eAJ zq|>(a&}!}{*OWsi+T^K#-M)q#c%|}V(|b<^K5Fv-pZxiS`{!mU+jbTh+X4rvAvQ0y zzD zsEJl+J#W#gem6fK`6~~tg7jk8X^X1L^H?G$1z!5piL~$c8I?BRS%30`Wj(QfT%Old z=$aS1DTEU>8BNF&uuu15VwGytGn|Syl-d8m71(=IELWv7I$ML6N`ps&Sc{g`+@YR?L} zwuJ+GCVB@2&ryjU3Z<;QZ`)A^4VAK){H1qxJKVF?joOAAl1wsY|Ex0pIlzC>)P1rj zvP=1r%N6I*P>k7P2Iho@3buT*mZdNQU&4S?fjK1(z7LB{oZ`-N9pXiI>1HrkV)j(x zYrBO)y-yGrOfK20xH^rj!Z~U2S);}g7<$WQ)9vB5?h-a-iHc#dEcPFu z0wMd)2K--q|I)HtNlzE@v9!gnfGetqi#qw@Uh+LRCC6nEma4iU1B!Q|YSXecIb)s+ zVB034g@a~8;CsT?n+Bw;`ybE$?`H+6t^CuAaOo!xC;=OOoV~l+h!s4WrUel()#$WP zY@Au&bdSkcl!#TRw$@2-E1{5V4>ex_d7@jk8=05l=mwdx}UX)aNKRl+k=>Pkx7X+#*u?Z_#cDrA5T>AF^|v^ zbsl^RUgA%;Aadv7{VgDsDJ>FAMsK=z#@$hk92C~Q^02;m$kGq&)|=c#6I=tG5WO@U zOyr)LVkmeT2nm zg-B&)54D{z=`tUVLeQ%Y>2r(tU{kkh`Zp=;Vd%o1qwUuDz) z`JJ4b>Ktl{8V+!a=2EdJF7dO$8CC@7N%?4xjlp--&7hZ3ueP;(-#O`WKhud)l(WW^ ze^fyk!#9ca59|f8RLmfBIJ-@7MtmQ|l$WAd(BpB-dg@Le&)&*hi%-9pYxqL5GDFSk}RkG*tpY_!wa!GugIl8_dxv8kSEa5$*plYX08k z`>TuNbrJBqb5BP&oJ=ulyTGQ(MPRhn^=LU&Dla}Kh!r202Ft*dS_4%>Pk2HPgS0y`Maes8TXWqV@HOg(L|2SVj#(hJuU08SfG zlh&sG<+9bE1I@pbnk{DK%ymrcY38uF&l6lz-*OJldg_3tJyu9Xehe(2$3_-1W>Z?_ zqR2Nd{3H=d4CV2(RbZ<*&$T718qR)>LiZ5j zE-e+XBVVrRc@(w>;Wy=!)k6Z{@K28!IAN=BdCm7)u$j6s{NTITrsgS4{X4ew7hl)+ zVCEaov>l96eDC;prsJmv3xirI)(ZEs-_E*lM9yuE*3DIp z%RfT)udYlOthlV;_`fnN*0z1!Qj-<{G34VR+g$3UiJfLDQKh8!<{LdnuwpE&+3}ft+<_=34RtIA?M|avff;1C0AQ|(bKRrsaY6M zBwlhe64FV?e(~$#{WtU_^WOX+AO2XP&@k@ucASzamWX22tefGzkm7Y1RgxHl%F*1K zUy&1VH2xTzw6PY^Jf1gR7He%Rf=)E4V|HlEqnQloJ8;7lP@>Vv_)Ul?qjd>)w2QR)31e9Wdlw zs67VLN%&~{v-isI`I7kxHIvV+*Fxt(PM#xn4?a!yRA5f>Iw{%zlbOiqEw}!KBogJg z^zed?=ttVOhmy<)aLv+ic&C)`7VS6Z$vX@>c)tXh4^l{}r5w|3ft7YF4Cq}W>h^Wl z!Y$~6SHiMiOHhDfe8jI^iL04#b@}b`c<%G1xRET&eX*s1r~j>f~!@*QH&0#YSk8@)-x z&_?;s8Tg-fm_N4J)XFfIZEnrzna$&tWt<|AMb9^PM4afJL{QTXrR3iGpdW+*t8*kX zwtHtE1ll$%MHUL}$qF0hP#6V6YM00@kSi96IX$a`yqz0s+7JTo* z>P_qM@TQE5Q5HZ{CW9Nvj53KFqn*)Z^}NYrE^NUNxU=Z zEEThTHfCyllTM_*+c*;DVK_QLENIaGoK!4itkq))ZW`Ud z6|C@%SY*$N|Dl1P*Y*#H)y4=Yx%{Z$rN82sIZ)4(F1a#4D)Oi~+{L7K2&6h37v)@H z7C@^A_FJ0%m(cp(=HQi!t78%Ovq2RL1cr-pJNY8mI98J`#8vw|rDCqG)m&?O7>}as zxTqIs*TufJ#GjgJ9LsUzz}77t=!t(=sAdx&<)3cKME?9o@DaX&n6j`+^gj2!<7S|6 zomAT?0AC~dd$AXAzZUhBL=66H6Z>Cwfkm?Mjm?m<3bANuD}}dur-hg7)ouJqIRjD^ zF{|jJ-!*=z5+YD~Lkvx*uMUC6B8hGj(JKn+8~yY3RiPq}A{G*=-czFFkDweyYu|yVEYp z`4rydzsjf=it?G4z-83?b29PgRYIl~G^MF!mrz2da69rM-C_pny; z;BtXT7nN*^agQ5RhEV7@%&JIuN3J@2+odkX%4;!U}& z7rV1Upu02%&eCM^S#lsSUhX`l(bwIM7{f8z*XDesX(2_bxV!Dsk)Xih?ep=+ozhBr zc%`s_28oBdgZGns7_A#-dxMY7@VX1G{^MQmJ>P6fH;HT#R)aUU8FBkBqE{d#QTEaW_5*o)JM}4- z!Fj1}n+BZ2%l{Wu^S@Z8>fO?K`)i)62`+lq#nscBqSDB3<>(9$g@_bg)f{|oN)+tF z&8_99&=g9;9=krdd)y=gmVHZ6P!-y@ML5yb)`p`0DhVaGjyGIF@CZG6??)!Z^j|e2 zGM4KknWkI4cCd42Q>hB0Qa5j&ir1v9R{QaJC`h<|lH z)N4LBDT-ti)R`P?{5*QZejF^MU49 zYg|20;J&4r?Tf($3Gj9dAryF92O=_^*Ahw;JZCS{MFpw=v^Y;tdI;ko0}v6nT!HnU zlW`Ehwt2($dF=ujXjoBt z_>e{huub!)>d?Q!Y8O0%NitPxU;eDqW=pPPn=0qHU5MJBKj@7RsG@u=)j$7kLt#P z5SJA3TYbT*fDOAact23|N6Ahax|k~dyJlo^T|Y>M?th5z9=-yMde!$JRzb$#x|iU& zo?v+uN)Hr>DW63+Pl+fD9MG`RTJ3hOB(LEs-buohUT=}8;gOdACga04A{#3~T literal 17853 zcmbun2UJu2(k{Lc5nmAy8%k9aP!N#b11d#8Kzc7yrI*mVAR=AqC4fqk-g^j52)#q7 zL2Bq70)%pR-gCbDKleNDx#zp*pS4_&#I?zuncvJa&peahw+hmv*Ql-m06;49T0$8B z2p<3dLG#rs;475I1?}Ju0;sa|OQ5iqW(5Eq05TFURNY|f(^l5B=02F?85PXSkc=nV z45r$pJ&&r^u-x6XNHj?OH!&;@uo!nVqTCaY|)o*{Xw6 zdacYk!JrxP-;~oH+o~nG3TkJ)nLY{tU}AEzoKV%~H zs%13#8QAmT=D~@4K`p7Q-1M^En&>8HOSf?NqSISbD55Hok4vja6mz=*E+S}gZm=?c zYqFjW*j1a_yaO6Q_-mpX!TwK^It}p2I?qRPL*_?*0N_VM0r5+TC^94fib=1;yw?o; zoj_W_TMPSX4L7K+5gw_iw}{Kbez^ewZioYb;GRy-Aa&1ng2&d(Pj%JN^fuEY759xA z<+o`t!L&Q#;1$v++)B%z7U|S_rg-?KUHbeAAn;@7QKLTK0WTcOD}V<)tl9QCvQ#R8>qOXBEozvQN=6V=yLi+*j$!>Bhy&^P`el zzKx#LMDMDMca-|ycKQI1+hQ+?LA!mS1}_d8Z3=G-09g10Tml;Ta;OzQ34L=MzEuo- zOUA!5#>9Wehvm*Jp*Y9fQ%wM{4oJ3K%bK7Huf83Bl>zX$N^lwQvnkF}-yf4!8y5d9 zN3zn+W_b=ck%E`Mmz6tz$EuspSt+5*ON+RtFj#e?Rco>8ybc#i^@W6eD+IpK(u0n2 zT2K6*=zD$&opn|mYNf>VxvFY>b`tjc<#qBRZMCwkl= z1VhyF#~w#k?~)a2VPi=BM-G<6i$nL3`zxNjxDIa`d@iS7B`M@Al7e|EC_ z%N818&JZ_rYOhid$roVE&OAsTTVHc=4)2p|!*HP|_kd;5)2p50X0Ym0FAD2l|7S5KS z$KJ(Ni7gX=xDWW>`@61E(XujmS;wiQ_~w88ua`k5WUKNa^GO5c2&!7d1qZ&>uTH#o zLe~;5U%z|<&@%_mp4RnyErM)_leX31>*_MSHS9G#9pK6XAcp0`-Klwp!(9pNwuz4Lgqn%(HLWnpSqZs`K@{ z-9CDq)|PgURB`ytfiWX4Xs@WjQ$BBPwkXnhb?r)HizGnMAI{SGz|Ul)&0AWUPY4MQ z*z`QL(hgU*+)OSA@7yN5Vgx56SR_%634;t?O2uU!-@q_mS zK59Upf{%7me8iEd<<@QJ69Mfs;IrEP50yN>TQ0tr6?AMK-P3vcbNInWV3}>-52()0 zS#^`NR5asGE|OvIGCwEI+e0`NKC^SWo{!BQXS4GXe^eLJv{<2B#Ent!Av!CiOLKO- z^_??P*FybDP|MTwm3qWLS}GUi6Yxr7Z`{I)*q#-zO+d#HQ!cVYTT`mOhcj>OJiP6l zyYi()L@HS;`xpf@<}#Fa5`tn`f>5_2St>=qHH|z4Z_3NHSV1xd=p_T)OJ#cbEZ9>M~>3P;tpLUeXOO z<)0CB(ZL>YeyG_bmOj}uM|fJcH#|5t+|EO#T-kEi^Qj8VtkAORmg|e`aW66i9C~}? zIv@LqOvL0<4|VZRo}{11LzB0tpIH>sIB;99Hw?@5RMv|&xLc^?t@RLdG+DcmN<@f0 zAIx@q;5TL2MzoSJzgM8hw@*oLYql$NfOy_eTz>aDxys0Z7DiXf&JkF?>qSHYy5^q_ zz<p>EKI3rE#eP5|AuBH|#5Z9zx%NH{|DpFHY;?I& zUB!$tM#){Y9VIHIpvdhAMF%g@Ib>kp?j z6iu)eooj98h_=lMzBQYQb9-)qK6UPq0pD7rE_&;HMx)%f)n_LrH7@BxzOJPJY2oC? zx&}RG5HR-r>Vw8taX+6wbua7Yu&7+z^9~T<1`lSE>myE6SDbRrM7f={z6WOTAvC31 zD`RrDrNK5_*saihU8CHw?6hr$hzCN_A5;HZ&(_zoPAb${PbXgm_O|dshOtzda&Uv`VRDsRc7Ydg!7Em z!L(P)T4SlVF0G~lE0=cn)@{=y(?r071Ya%;Cl6GkF8Mux5DiKXhTDV5Hq>2}0|2zZV_Q>=3(7i7mmJVDt5C){i&{iXw%2J$2Dd#@*h`c8 zc-yJ+D{FA!1}SCs?M2nOQ^IeAH^lv>yqUwMF2Wnde-T3YY8uAwrn5Zrs4>i$jf2N= zUwg=jY@T>KRobTJGaEy8x$A0*?gr(7gRS1f>HJ}Pmc$WS1;xC4X|-z+C#v$^xmhmM z4;}hUh5bZwqsVeN1kB1{s~kb=XbQmX#^oEb!*|vg`J9J-M zyW+2hCVMEK#-e6 zP5HInltVOeAIaK5TwBlvQ@`)_aB59nm3GsCfq|{%p5jLGk$h(DHxK)6q@|_69409} z$ElhQ!VkYtRmK^+vI>}`_Z6nmi@qI&2Dv8Vw$H@$#g02GkP4sLG91knSB;84Z=3T9 zxxdZ)EfO^xC&rl<`=(a|pku#uRmd+-lXM)*i5jgfHwSqUo>i`(9feTGO?tM$Gx zMANLxZBUP`$xP{T-i=eYvw(U-Z-L%IHc{iQw_J$MmwIbNURzT}Qy2 zc;RAqyd)`1{Dzg#+?J;#u~p}LG2CZ&Sqf52X03`kI5x!H*yx=_ zk1pDZIkjMeo`vW_%0QvQqBll#?cj99J};`3Ib&m()xDccvP#=$@II#8-$CJsdRCFr zu5Ynt$r(ub0H&k=swe+Fca4X*$x{2Jgo2m|D1CJV82-j%pWjUsB7g(~n5@W9%*<;9 z&1{?ORjprcp7~wraad^|J=}aqMh;*<0Hp!&A3HJ~iKYsapOVb^$#v3oZQfaoJY!7G zjp%tjJIIOI_vh`Sr63AGaH-Lf#%X)ceE1$r@70X>c2U;=hr|DEzcE_z%m8RYSF&0!%%P_)jXt>eMP!xbmVxvR*Ed1dD_FxT}RR&ag}^4nRRVYWh*dJQAR5Bk)Og#VEhIA+8t)Q zIKfO)d8yl&k=p|y>#zvq?bodWP}>n}C!dw$62ZJ-`*ieZLDrdzURiA0?W;*dwx|VO z`|0zV5p4K=qA_~x{i==>5bUjXp*7IuF*zv6mg;VN@R1j_KTwi@73*;n-Kw}(mX;kE zp;x?Q=}}Zm@oQ?s+nf$rBxM}{CWm~eOJn3tA6mC#!AvVkf8YglMpxBi&YZztGt4!$ zlDe@$WFT7_`$?x4gry@hwYI&ybR`P>AxTcH*1fM*D$IJSgHVl{X`M56Rk{2#6>HZx zHf`P?lx#cbj!zbGC`!OT>`1*r+|fU#mP{mAFss+YF<&gn?>V0wq(L0teS=*#$gW`3 ziQWv@Mm$|t-@qK~JhXBLYKi|fnE#99963t_I29zLQfzHL(#Pw$3=So`Q1(^S=Nz}| z0F42U{MH{RY)pKUHOrY2XH^nS*Ve(P?&&Xal|^Owj0;lgqSaYu1LEYu^UcXW<%jwJvJ>2oJ z7_!nBqSN4#e*H+}X1MMjea`idMY$;0F>jjdKls|bb@{sBG134A2;Y;UBCJ*dAsw129ZTb30HzteRtZ4FE_f& z>-+eB|0jEa!T4&bzy`ev?ScyR7l(I~1PP^7eZAf*FQw?9@w^*z8tdD7nt=$y}L~LuLlQT&~`evCJ5E+q|@q z<%JiqJvPoNHX9MuU;8>YMRN}6uM+z=gT4;hFlwaq-Lu)FqJfgh7anzfHLW{(D;w#p z#@aN`vg8~+Jb5zU@_jJvqejEgk)LejWcW6PB}Z?)JjKKQS4WpGr!{HhyH@@%-_32W z&OwlC^+faLO$)o!mrbb)>qk}pVKq5}M06-4H|FEKkOjAFyN+lqL)@pQ&YkW7kcNme z2)@ztL;qJl^55VXKDmJfTm-~!9#I-gr1Z2upJeM%Y-N&4gpI4_>PTU(;8UJB@XgBE zyfIwvOY-cGW4+0hx(n>g`GsAdT2!4%C${P!Ub{4ez}3GL%0eAE zZ^}wgO?jyu^ZaM?Q>!;o?~wB;M&fyI3*z)-G#$`57pCxDG)^T-*d{DkI}Y1faG5Q& z$aE~xh+ZSub5`NqyDMF+FilNu_upryrDVMV$AVTtV~$Pkk5>jI zSC$U!-`{)UPAg)l5fYCmmMi4Yl{i}Q)T#B!ekxK~ZEw@wwS_~UX;HoD(RS9b%h}$k ztqDumSgBW#yO#P&$NN0+;Ex_n=-IAg^~bkSxobHtvCWeh$Ef9Wh%n9O*?W~&^fpxK zr*p>0wg6vxybq3Pd=%(=F?A9KUz;hNw_;@szhnsWXC|J8t0(WB^;9J7x%6pt&+x0! z76@Rvslx>dLSm*JhPrI|m5`Tv>;a3ajI&p5(|H+>J2s=k>2Y~?_NqO=dDBt< zWQcr>;IaF&uVuEMG|tT$h8Inx8z&)Slc)I?bxc;mJlcbdbszMlmg{=bN_*2)MmW8n zkCpe4bR72p@(aWX~r+jHlU6VMo8uVOR35c&cr z-T>QDKib(VvxI=qC9wA@el!(xR`C#MY>@^CfPHwah(W_8Ke88pKH-%-dqM+zAp`^O z8)oN#6EK2f%Q}n)X)do``RsE-Ah1i_c_rJIo zjc;3N-5!=vo)T(hFrRfvl)qr9%=OQ@Z3CLXf^r8JdZ?nz-k6XYu$v z72o5aRuzw;+pjo~I%0BlO^fDQ9ef?bzN!3972Cs{bB-aI^1lc)JCpHxnmBl7g z)g7oORF!GQed=mEb(&mc5W8@Ds92vx&UOo{&%l2H|E`xa0M|dP75FNjXchL=YVbE{ zovwrWe5_WWfkD;a{yH6HYMvruS7LJW`BTQiQSSsBPUj-vf*q#k)RFpn>8vUbh%qBJ#ksZnsc0h#8;J-OCQ%cyLy)_!k)AHeW zr(VOw&!73ss(ctWpPUSnIZwM^eGSfKgW7-irsQcM%6`JEN0e#HSH~G;a4gX z$2`r6jKP{Y2oQM#GbI3oUv_8HN5#-A4Uq$G3HT68r93;x+mZMq4Mbb7TD9&1 z{d-0YK57D;Wm>@RcX+zb13fdq4h^Gr^g1B`1b@FF0hTSmzxV!^vE}iOAg~b7mqY+$ zDYsbIPqXt)30u$n;)khJ%REAFit5X7>jHwqZ!&teMQqz8&yKuM-{KEH##h}(UEDKW zNXmof2XKGqj34oOn5Da;E@iWAS0EGAc}QPl)tGlrMe&j$a^(cz%G;6svEa|U89l4M zF+mx|#3_TOj4&Pe1urhI6u|pB99k&K@9p$VQ}ZB*q7|x8xa1ur^IJekXWw|<#6^-uIBJy&84H+d(Og@zAu4H+AYcwy5 z5$7CRp^-Li$G%_i359Fx>1(XbQ$<%O1{`>=1UFnb`4;bQO^aD{i!c+(QOmsw-Y>5a zVDql##D5Thwm&(c+~boZh>#f_(qdInuJyNr^HD)_Ml^rq{EX;C>8Ity$91DEU%hiq zZl&9J=4b5Ap1+Ah@ehusIvz=(4;VcvsnEVr`;(Z$v7$w7&)yDVxeF<^;TOgiGK1w1 zE`vP-u~OerL0aU2yI+S#fjPo(j+SdFjq5_ZZYiC6PHbY!cZ zR$LJ`>yj(8VT?@Z8p)quHKh&H)?7c%T@(2z#=2B-T2>km_E?_P%d1WHro7wy5o>jI zxPz5k*AS+~cV5-G{KsehabQ0#<(4=ARMU$lWdeqEV66kHshbD^Iu?Ab{7CjUwyBt# zLI^O_!PB9_#NP>lN?I`S_2aJ60sWl#SS@t68tR}HeQ+k9?WnLu2qwt{Ac{wA51m%o zwm4q$Ja4C?z;y$Y<|{Oz4d`81>?4^O&2rZt%QJE4y2ow*P2l0vF-ta?kt~%KO)PJ#4J-XlukT|0 z+!U+O4Orge5jwSTk%$>}Zq2qQGCkz{Y8M%FG||`NRUslK@|k%T@8r%s+}_NvafG9} zFd>|9^|w}!ydGdTIF_d?ws%jiv9D^E*}?N`cXoQr(ok}^b>mrjbj)E)Gcp!?Esjoh zFum2tCi0{kCGTr$HP_vE{@cU5EOT+M)gI&q&}Gm|ucuL^wNJYS8&75s_q;|k-`P*6 zqzqXi3p|T(^!rLolcx$ldrS)dgyHON0T@`9wYyk0c>voM*7zuC$ee!G_mM?LZ6ylv z;v)2=B^oYyJ0edBTakabr?EfqBg624_l9)D8x{t?>-;Lm_T!a>%(-rMeX+4|r*nUd zbldP2aB}+{dE`0`9T76drj8j$$V@s5&R;p}G?VM1*V=jBI~!|MpiAE1;KXLLY-AuD zy;y&t`N-DrSIJw2xl1`UKOO7tLZ88FtuBb}XMR$wa8;}Q2DV?i945+9=_Xqc+7 z`a_Sel5qtlif`UXMibT+E4AKMd1`1o^d^!H60#NPm<2ek#e`jI2PX;A^-Ew=S6`@n zWVR!hekOD{W@w)}2JD(ICVQCMRsZZ0l(9=t$(aaq_p6RqD{?JBkJ8t>_P|z<=VkR& z&nW$kKQW4$c9#vjUn55&jM(iK9fUJPDvAt{sbe+~xS5OXAA(E7NrQ%y6IqDELE*e5 zhl5CjfEfg}FesHfAn!@StPHyI+&F4YE)NeP`9B(zjYF$ z2(1T1bU|!1X5j{>0}Sy)fp3%3tZ)0%34mpB@Qr2lk0krm;aRBIk&Inl+UE@{HH>Rm zCP0V?%te4x4ERoSAsPm z8PQ_tO3qeS6LTYag)sV8!`;0y6vdS9lR-L|>PJ=2PM!BG`0X}q*6Me7+@kl#>}7%) zo*VT#FYk_)RH;|`LnX_uZ zU)C&st`HS*NSc6|AM!1Ac6=LroIaP`by#~*VmqAVJ?ju?=Si!{mVm1{h2`;v)ug)B zeVabmCOv6;D>tLrU_?hgL@~KHpCxwCs(w?1j@>7}qiDrXtGC8MP_)vDXDUG-p)WEX zRdp!eWBDRJL)ByYzG7Cb58FT~uTlo&*x2Uy_lz`&Q2%|<46g^vEn#o{$l@Q{HskE; zTsec^4^+;;VjuH%>4!sz?0op-^m|H0XOG@08T33i)HG`DeoFf}sSCB?dQQx+R0I3T z5LlJ{SQN*6czi_3Lsue^Gh7@QlpL;}X(1BnCh&t)KMG!U0eR|0ctOj*wvkZN&4~#W z;>DRAxw&Z(n@%sw4KDJIAWHA1u2J$7`tLA=P9_~&$?;yg(of64u?XfNVEZ>%>%fQO z&@DW;1M1D+2B)f~orH1t@W!&AyVka&iu~B|z#pJB1>aImLm1TMf3Hy894<$QrXf8D z+OH5?ZXf>~GV$DphXfEFI3prYNgc)M9jl5PW`mGU5ElZ`7D?~??mq=1zkQPqyh1@O zT0^vNgwW51^QVNbVt|KgX8&`4;(kJqLg%aATV6IcSIzn8-%dH4O~|tHBk3{)Ks(RO zt1Oe5NIxiE9Lm&25XI32S{5IxWjK~59-jTlZEcQL` z37V-p5=Cr$5(B7d|-x)lf9BDXAfXR3b3s;A?`quRv2Y~#R9kTbGxK=q_O zuwivnHnYjGDM_~V>vp)(XjZNh$_Uv1+MJh7tb6u+;CK1|LV0vlnwYm^i zdVNdnYjeFoi4+TCY^aMcG8MaSQ=EA?t`ul+YfYAwT-d}00RE;0$l0%D@OWdi-;txa zyGz*9N7pZF>C=*upxz@w2RIR5YA1{O`S$CyJubyO{D!!r6Q+4$h!Pc;IpTgFIie_1 z<;#9xv3*Z|Qvc#eC(lg9E#KfGlCms`g}Zm9`jSCkbVh8+;pxThY)B2QzUQjo9`DSA zr5yjJ!=CF*^BS8>YdzcE+z^KW&aP+zcU;lZC*N63alRd`Q%kq=#~e2sjtOyQVzpxc zL)GIq8n>M3 z%6_naOfhD$42~hbF>l+G0Mj2p7DHS^PVxxom;ZZw{tLPTxqS)n!+}#I9E07Xq@~CO z6&WeP>wc*(K#Ebh!s?90$>ytG5y;S*E3bkx^(Y)9LJOO4AgjWLWo8%Xy_115i%975 zbU61?q+dhl-7K5tr#h5Rk%LXhR1+FY42nx=z3WPlj=BP82qD*7tcnOTQyylPsS6`N zrQX%na%N$LpW9Z898YwA{aq5fSc3ia+MrbkogAOcvRTQ2*^oQvOnfxo#MwYSP*YP; zuI8zIRzW}PCo16Yn~~!rl1E1cOgAEwf%aB>+3U>Gs&-kLTK#@~DVWdOsT$umCqI4ao|Z}$E;uN=4)XMSfwzV*8g$inAcyeZ-m|}&2Lm|B8(UBZ z1PgrTd{^ph5!5S2uFcZYyh6zL*$zm#r!tnE>;fhoQ%f=|T3f>>Qwai615&{*O_;Ux>zg2?8_U}K%Y^s8}GMR9ZZDo(Q|xiJN+odqBvclPHt3P zuozf1>fePcc$6L`*Dd~~q_8dmsIH>>11u*CnVv0dfYT)(u-6jjX(k5nGjA|-{*4Ae z9wqk473`qmoej}c@z(|9MY*Y-G0@20I;;Ow-_2U!w8Od;m=>K{_2=}TNUp<{wO2<{ z9voTA-0#z+*H+yB<#{-}H`_Bh-t&%LSKnIWGn5MpRp(32(TZV`2{5f%hcT6EW?@6d ze3dMFJ47IplG&vWa7tL{_;sZ@OF^$5LGw)v&q0^Wod4vsC#oXQ1mE@Y$uuKJ3ds3c z0u;&2O5tDY~a7~qGUE1yPzQW{^=NOZ+7p(Sw@c0 zlN#2wOqO0TIQZf7k5*L+$-@IuapK>s;0W#?)HVO3I8og9`z0_K1>rfL&|zOao{fcI zSj;2BX7=u%%}h-UT5o6<_AwY}YUjw)({{a2EM%J3S}joKldJ7PZ9ao$8&iu%4X0Hn zsfnX($3-7xzSIphS+D+C^MlG?)q<+kQ6cxvqa|5o=KIi!q{679ZQZ(*p*l;es%FWM zc=o%)))^DLZd;~zLvv$0$DINf*yPVjp()M~eWg4X@x=&jW`NubdHIX^#O4C`#x~Ki z`hc3t**5xj!jg0{6>fP{rf5G^Qa@PXj-Y^NEM!B^el%LM}% zE6~@8&$bkfb*0wO^}R41-pF{cSGJazeN4GZ#;ZnKB!EH~hUTde{Xo9E)`rtgSk$KJ%n-0kvA8!y>ymCf}p(J$n%Ev!=Ks~EVNW={@LS2x9q z+joQc(S?n%lxjk!t--a9=OCNWm5;}4%M{zZJ@T^nwQ|%6YQyFLKdi_^zIwEV$;77d zhSCx^eFZ=J`M_YO(Qspx2K{c56CyA(HYwwHL!aEMD8U;lOU$C5RKCQLtEm)J$%!ou z%v9T};PrM|ufA=Y?xIlPdl0@@=#_Fv{u&)5Ci$840XPesc>fVYMZ>ZD`<-#~frORZ zL3Q9^abPCzp7wD_n^hDy;cH=reD~v!=wzosQE6riyC4XtGvj~oPlssC#THaj;>)r) z`5g5S0DM!S5ci{7l)OREuXl9&^4NWsZk1xcl-11}&8?u&&6*#Sfw+aZA6v z>@B=>>r1?Izn9r@F4c+Jc;9j)Cm3|E3w}dSGpo%D39&7#dQP$+mc6Ds$0$=qRrHu8 zA?0bq&q(OT!9bcG#w>~`Vd^{ zLb`yhL4(RbcIp{gH%o&CpY7m>&_YTKdS%6G+c#61{gift>s3Y!9nSLi!NELL;+qUq zm*a`gMu=vq@KJ^{%CiBB~-f;2^sJQk^CBW(ob%iq|9uu;{Q~XW0o@Mt3O|oH#$@b-@M80hi=Bi z{8PyOJABX+u|kkIa+IOMb)RZT?91v7vkJ7Z;f=EQ+e)dLiRQu8Q}6saQF%3KW=0~E z=*40yOYfxjlR(L>$;6oVt+uRNrtFb)lqNRn^LMgM+NdzGz2+l|H=+5+p851q^V4*H z!QS}t<)BIPSH`;a+y0NCIvbvqx@**A4e^yl1|D8xkg6@}(wS(_I)t;AYl=4`dFzV2dPZ^Y-MWW&8h`LK19d z|Dr5Pe$@MIN}CYv#mJMzZh)h&aAYWQzO)Ipeb2HUuU}?-SJid3fb#+_WYoJiDHqS2 zG9+WAdobzZ>3;Eul$7jK*8!gkqX+9$_!F7hlFT>Pc6@#gq>A)w@LV9mYo`Uym2_$i zosMJI6mHE_c3snG8@gpRuMTy!Y7=ywf<0?SC|+Zh(&)~z?_p^`jrLhO?S7M8LRfUA zwOiI3>GQ)3{5F+9VZQivgRfI2?oyXcXdoHGp|ip!HaAm=5I(s>(3_%0A15;{8dN#`vwDYu90&X+^> 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 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,