Skip to content

Commit 72ea461

Browse files
igorDykhtaIhor Dykhta
andauthored
fix: aggregation layers regressions after deck.gl upgrade (#3383)
* fix: fix Map legend is not following hex aggregation values Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * more fixes Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * revert, allow double event fire Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * igr/fix-aggregation-regression Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * fix tests Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * nit Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * move to shared utils Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> * follow ups Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> --------- Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local> Co-authored-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>
1 parent 2938e27 commit 72ea461

13 files changed

Lines changed: 303 additions & 22 deletions

File tree

src/components/src/map-container.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -575,10 +575,12 @@ export default function MapContainerFactory(
575575

576576
_onLayerSetDomain = (
577577
idx: number,
578-
value: number[] | {domain: VisualChannelDomain; aggregatedBins: AggregatedBin[]}
578+
value: number[] | {domain: VisualChannelDomain; aggregatedBins: Record<number, AggregatedBin>}
579579
) => {
580-
// deck.gl 9 native aggregation layers (Grid, Hexagon) pass [min, max],
581-
// while ClusterLayer's CPUAggregator still passes {domain, aggregatedBins}.
580+
// deck.gl 9 native aggregation layers (Grid, Hexagon) pass
581+
// {domain, aggregatedBins} via our ScaleEnhanced* overrides,
582+
// while ClusterLayer's CPUAggregator also passes {domain, aggregatedBins}.
583+
// Plain [min, max] is a fallback if the override is bypassed.
582584
const config = Array.isArray(value)
583585
? {colorDomain: value as VisualChannelDomain}
584586
: {colorDomain: value.domain, aggregatedBins: value.aggregatedBins};

src/components/src/side-panel/layer-panel/color-scale-selector.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export type ColorScaleSelectorProps = {
5656
searchable: boolean;
5757
displayOption: string;
5858
getOptionValue: string;
59-
aggregatedBins?: AggregatedBin[];
59+
aggregatedBins?: Record<number, AggregatedBin>;
6060
channelKey: string;
6161
};
6262

src/components/src/side-panel/layer-panel/custom-palette.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ const StyledInput = styled(Input).withConfig({shouldForwardProp})<{
208208
width: ${props => props.width ?? '100%'};
209209
text-align: ${props => props.textAlign ?? 'end'};
210210
pointer-events: ${props => (props.disabled ? 'none' : 'all')};
211+
font-size: 10px;
212+
padding-left: 4px;
213+
padding-right: 4px;
211214
`;
212215

213216
const InputText = styled.div.withConfig({shouldForwardProp})<{width: string; textAlign: string}>`
@@ -216,6 +219,9 @@ const InputText = styled.div.withConfig({shouldForwardProp})<{width: string; tex
216219
border-color: transparent;
217220
width: ${props => props.width ?? '100%'};
218221
text-align: ${props => props.textAlign ?? 'end'};
222+
font-size: 10px;
223+
padding-left: 4px;
224+
padding-right: 4px;
219225
220226
&:hover {
221227
cursor: auto;
@@ -400,7 +406,7 @@ export const EditableColorRange: React.FC<EditableColorRangeProps> = ({
400406
<ColorPaletteInput
401407
value={noMinBound ? 'Less' : String(leftInput ?? '')}
402408
id={`color-palette-input-${index}-left`}
403-
width="50px"
409+
width="54px"
404410
textAlign="end"
405411
editable={noMinBound ? false : editable}
406412
onChange={onChangeLeft}
@@ -409,7 +415,7 @@ export const EditableColorRange: React.FC<EditableColorRangeProps> = ({
409415
<ColorPaletteInput
410416
value={noMaxBound ? 'More' : String(rightInput ?? '')}
411417
id={`color-palette-input-${index}-right`}
412-
width="50px"
418+
width="54px"
413419
textAlign="end"
414420
onChange={onChangeRight}
415421
editable={noMaxBound ? false : editable}

src/deckgl-layers/src/grid-layer/enhanced-cpu-grid-layer.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import {GridLayer, GridLayerPickingInfo} from '@deck.gl/aggregation-layers';
55
import {GetPickingInfoParams, PickingInfo, Viewport} from '@deck.gl/core';
6+
import {enrichedAggregationUpdate, enrichedRenderLayers} from '../layer-utils/aggregation-utils';
67

78
interface GridInternalState {
89
cellOriginCommon?: [number, number];
@@ -24,13 +25,29 @@ interface GridPickingObject {
2425
*
2526
* We override getPickingInfo to add `cellOutline` — an array of [lng, lat] coordinates
2627
* computed in common space so the outline aligns with rendered cells at all latitudes.
28+
*
29+
* We also override _onAggregationUpdate to send per-bin aggregated values through
30+
* onSetColorDomain so the legend can compute proper quantile/custom breaks.
2731
*/
32+
// @ts-expect-error -- overriding private _onAggregationUpdate to enrich the onSetColorDomain callback
2833
export default class ScaleEnhancedGridLayer extends GridLayer<any> {
2934
static defaultProps = {
3035
...GridLayer.defaultProps,
3136
gpuAggregation: false
3237
};
3338

39+
// HACK: deck.gl 9's _onAggregationUpdate is private and its onSetColorDomain
40+
// callback only provides [min, max]. That is sufficient for quantize/linear
41+
// scales but d3.scaleQuantile needs the *full sorted array* of bin values to
42+
// compute correct break points — without it the legend labels are wrong
43+
_onAggregationUpdate({channel}: {channel: number}) {
44+
enrichedAggregationUpdate(this, GridLayer, channel);
45+
}
46+
47+
renderLayers() {
48+
return enrichedRenderLayers(this, GridLayer);
49+
}
50+
3451
getPickingInfo(params: GetPickingInfoParams): PickingInfo {
3552
const info = super.getPickingInfo(params) as GridLayerPickingInfo<Record<string, unknown>>;
3653
if (info.object) {

src/deckgl-layers/src/hexagon-layer/enhanced-hexagon-layer.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import {HexagonLayer, HexagonLayerPickingInfo} from '@deck.gl/aggregation-layers';
55
import {GetPickingInfoParams, PickingInfo, Viewport} from '@deck.gl/core';
6+
import {enrichedAggregationUpdate, enrichedRenderLayers} from '../layer-utils/aggregation-utils';
67

78
const THIRD_PI = Math.PI / 3;
89
const HexbinVertices = Array.from({length: 6}, (_, i) => {
@@ -35,13 +36,29 @@ interface HexPickingObject {
3536
*
3637
* We override getPickingInfo to add `cellOutline` — an array of [lng, lat] coordinates
3738
* computed in common space so the outline aligns with rendered cells at all latitudes.
39+
*
40+
* We also override _onAggregationUpdate to send per-bin aggregated values through
41+
* onSetColorDomain so the legend can compute proper quantile/custom breaks.
3842
*/
43+
// @ts-expect-error -- overriding private _onAggregationUpdate to enrich the onSetColorDomain callback
3944
export default class ScaleEnhancedHexagonLayer extends HexagonLayer<any> {
4045
static defaultProps = {
4146
...HexagonLayer.defaultProps,
4247
gpuAggregation: false
4348
};
4449

50+
// HACK: deck.gl 9's _onAggregationUpdate is private and its onSetColorDomain
51+
// callback only provides [min, max]. That is sufficient for quantize/linear
52+
// scales but d3.scaleQuantile needs the *full sorted array* of bin values to
53+
// compute correct break points — without it the legend labels are wrong
54+
_onAggregationUpdate({channel}: {channel: number}) {
55+
enrichedAggregationUpdate(this, HexagonLayer, channel);
56+
}
57+
58+
renderLayers() {
59+
return enrichedRenderLayers(this, HexagonLayer);
60+
}
61+
4562
getPickingInfo(params: GetPickingInfoParams): PickingInfo {
4663
const info = super.getPickingInfo(params) as HexagonLayerPickingInfo<Record<string, unknown>>;
4764
if (info.object) {

src/deckgl-layers/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export * as RasterWebGL from './raster/webgl';
2121
export {default as WMSLayer} from './wms/wms-layer';
2222

2323
export * from './layer-utils/shader-utils';
24+
export * from './layer-utils/aggregation-utils';
2425

2526
export * from './3d-building-layer/types';
2627
export * from './3d-building-layer/3d-building-utils';
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright contributors to the kepler.gl project
3+
4+
import type {AggregatedBin, ColorMap} from '@kepler.gl/types';
5+
6+
/**
7+
* Build a Record<index, AggregatedBin> from the raw Float32Array of per-bin
8+
* aggregated values returned by deck.gl 9's CPUAggregator/WebGLAggregator.
9+
* The resulting map is compatible with the format kepler.gl expects for
10+
* histogram rendering and color-scale selector.
11+
*/
12+
export function buildAggregatedBinMap(
13+
binValues: ArrayLike<number>,
14+
binCount: number,
15+
aggregator?: any
16+
): Record<number, AggregatedBin> {
17+
const bins: Record<number, AggregatedBin> = {};
18+
for (let i = 0; i < binCount; i++) {
19+
const val = binValues[i];
20+
const count = aggregator?.getBin?.(i)?.count ?? 0;
21+
bins[i] = {i, value: val, counts: count};
22+
}
23+
return bins;
24+
}
25+
26+
/**
27+
* Classify raw per-bin aggregated values into break indices according to
28+
* a custom colorMap, and patch the deck.gl internal `state.colors` so the
29+
* GPU shader renders the correct color for each bin.
30+
*
31+
* Custom breaks define thresholds [t0, t1, …, t_{N-2}] for N colors:
32+
* color 0: value < t0
33+
* color i: t_{i-1} <= value < t_i
34+
* color N-1: value >= t_{N-2}
35+
*
36+
* We replace the values in `state.colors.attribute` / `state.colors.input`
37+
* with integer break indices [0 … N-1]. Combined with `colorScaleType: 'quantize'`
38+
* and `colorDomain: [0, N-1]`, the shader's linear interpolation maps each index
39+
* to the correct color texture pixel.
40+
*
41+
* `rawValues` must be the original aggregated values (not previously classified).
42+
*/
43+
export function classifyBinsByCustomBreaks(
44+
stateColors: any,
45+
binCount: number,
46+
colorMap: ColorMap,
47+
rawValues: Float32Array
48+
): void {
49+
if (!rawValues || binCount === 0) return;
50+
51+
const thresholds: number[] = [];
52+
for (const entry of colorMap) {
53+
if (typeof entry[0] === 'number' && Number.isFinite(entry[0])) {
54+
thresholds.push(entry[0]);
55+
}
56+
}
57+
thresholds.sort((a, b) => a - b);
58+
59+
const numColors = colorMap.length;
60+
const classified = new Float32Array(rawValues.length);
61+
for (let i = 0; i < binCount; i++) {
62+
const v = rawValues[i];
63+
if (!Number.isFinite(v)) {
64+
classified[i] = NaN;
65+
continue;
66+
}
67+
let idx = numColors - 1;
68+
for (let t = 0; t < thresholds.length; t++) {
69+
if (v < thresholds[t]) {
70+
idx = t;
71+
break;
72+
}
73+
}
74+
classified[i] = idx;
75+
}
76+
for (let i = binCount; i < classified.length; i++) {
77+
classified[i] = NaN;
78+
}
79+
80+
const classifiedAttr = {value: classified, type: 'float32', size: 1};
81+
stateColors.input = classifiedAttr;
82+
stateColors.attribute = classifiedAttr;
83+
}
84+
85+
// ---------------------------------------------------------------------------
86+
// Shared _onAggregationUpdate / renderLayers logic for ScaleEnhanced* layers
87+
// ---------------------------------------------------------------------------
88+
//
89+
// deck.gl 9's _onAggregationUpdate is private and its onSetColorDomain
90+
// callback only provides [min, max]. That is sufficient for quantize/linear
91+
// scales but d3.scaleQuantile needs the *full sorted array* of bin values to
92+
// compute correct break points — without it the legend labels are wrong
93+
// (see https://github.com/keplergl/kepler.gl/issues/3381).
94+
//
95+
// deck.gl does not expose a public hook for post-aggregation data, and props
96+
// are Object.freeze()'d so we cannot suppress the parent's callback. Instead
97+
// we let the parent fire its [min, max] call, then immediately fire a second
98+
// enriched call with per-bin values (aggregatedBins) and, for quantile scale,
99+
// the full sorted domain. The second call overwrites the first downstream in
100+
// _onLayerSetDomain.
101+
//
102+
// This can be removed once deck.gl exposes a richer post-aggregation callback
103+
// or makes _onAggregationUpdate / AttributeWithScale part of the public API.
104+
105+
/**
106+
* Enriched _onAggregationUpdate implementation shared by ScaleEnhancedHexagonLayer
107+
* and ScaleEnhancedGridLayer.
108+
*
109+
* @param layer The enhanced layer instance (`this`)
110+
* @param ParentClass The deck.gl parent class (HexagonLayer or GridLayer)
111+
* @param channel The aggregation channel index passed by deck.gl
112+
*/
113+
export function enrichedAggregationUpdate(
114+
layer: any,
115+
ParentClass: any,
116+
channel: number
117+
): void {
118+
(ParentClass.prototype as any)._onAggregationUpdate.call(layer, {channel});
119+
120+
if (channel !== 0) return;
121+
122+
const props = layer.getCurrentLayer().props;
123+
const {aggregator} = layer.state;
124+
const result = aggregator.getResult(0);
125+
const binValues = result?.value as Float32Array | undefined;
126+
if (!binValues || aggregator.binCount <= 0) return;
127+
128+
layer.setState({
129+
rawColorBinValues: Float32Array.from(binValues.subarray(0, aggregator.binCount))
130+
});
131+
132+
const domain = aggregator.getResultDomain(0);
133+
const aggregatedBins = buildAggregatedBinMap(binValues, aggregator.binCount, aggregator);
134+
135+
if (props.colorMap) {
136+
classifyBinsByCustomBreaks(layer.state.colors, aggregator.binCount, props.colorMap, binValues);
137+
}
138+
139+
let enrichedDomain = domain;
140+
if (props.colorScaleType === 'quantile') {
141+
enrichedDomain = Array.from(binValues)
142+
.slice(0, aggregator.binCount)
143+
.filter(Number.isFinite)
144+
.sort((a: number, b: number) => a - b);
145+
}
146+
props.onSetColorDomain?.({domain: enrichedDomain, aggregatedBins});
147+
}
148+
149+
/**
150+
* Shared renderLayers() wrapper that re-classifies bins when custom colorMap
151+
* changes between renders without triggering a full re-aggregation.
152+
*
153+
* @param layer The enhanced layer instance (`this`)
154+
* @param ParentClass The deck.gl parent class (HexagonLayer or GridLayer)
155+
* @returns The sublayers returned by the parent's renderLayers()
156+
*/
157+
export function enrichedRenderLayers(layer: any, ParentClass: any): any {
158+
const props = layer.getCurrentLayer().props;
159+
const {colors, rawColorBinValues, aggregator} = layer.state;
160+
if (props.colorMap && colors && rawColorBinValues && aggregator?.binCount > 0) {
161+
classifyBinsByCustomBreaks(colors, aggregator.binCount, props.colorMap, rawColorBinValues);
162+
}
163+
return (ParentClass.prototype as any).renderLayers.call(layer);
164+
}

src/layers/src/aggregation-layer.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,37 @@ export default class AggregationLayer extends Layer {
232232
// eslint-disable-next-line @typescript-eslint/no-unused-vars
233233
updateLayerVisualChannel({dataContainer}, channel) {
234234
this.validateVisualChannel(channel);
235+
236+
// When the color scale type changes, recompute colorDomain from stored aggregatedBins.
237+
// quantile scale needs the full sorted array of bin values; other scales need [min, max].
238+
// aggregatedBins is only populated from onSetColorDomain, so restrict to the color channel.
239+
const visualChannel = this.visualChannels[channel];
240+
if (channel === 'color' && visualChannel && this.config.aggregatedBins) {
241+
const scaleType = this.config[visualChannel.scale];
242+
const domainKey = visualChannel.domain;
243+
const bins = Object.values(this.config.aggregatedBins) as {value: number}[];
244+
if (bins.length > 0) {
245+
if (scaleType === 'quantile') {
246+
const sorted = bins
247+
.map(b => b.value)
248+
.filter(Number.isFinite)
249+
.sort((a, b) => a - b);
250+
this.updateLayerConfig({[domainKey]: sorted});
251+
} else {
252+
let min = Infinity;
253+
let max = -Infinity;
254+
for (const b of bins) {
255+
if (Number.isFinite(b.value)) {
256+
if (b.value < min) min = b.value;
257+
if (b.value > max) max = b.value;
258+
}
259+
}
260+
if (Number.isFinite(min) && Number.isFinite(max)) {
261+
this.updateLayerConfig({[domainKey]: [min, max]});
262+
}
263+
}
264+
}
265+
}
235266
}
236267

237268
/**
@@ -454,14 +485,32 @@ export default class AggregationLayer extends Layer {
454485
}
455486
};
456487

488+
// deck.gl's aggregation shader maps bin values to a color texture using a
489+
// simple linear interpolation: (value - domain[0]) / (domain[1] - domain[0]).
490+
// It only understands 'quantize', 'quantile', 'ordinal', and 'linear'.
491+
// kepler.gl's 'custom' scale (d3.scaleThreshold with user-defined break
492+
// points) cannot be represented in the shader directly. Instead, our
493+
// ScaleEnhanced*Layer._onAggregationUpdate reclassifies each bin's raw
494+
// value into a break index [0 … N-1]. We then tell deck.gl to use
495+
// 'quantize' over [0, N-1] so each index maps to the correct color pixel.
496+
let colorScaleType = this.config.colorScale as string;
497+
let customColorDomain: [number, number] | undefined;
498+
const isCustomScale = colorScaleType === 'custom';
499+
const colorMap = isCustomScale ? visConfig.colorRange.colorMap : undefined;
500+
if (isCustomScale && colorMap) {
501+
colorScaleType = 'quantize';
502+
customColorDomain = [0, colorMap.length - 1];
503+
}
504+
457505
return {
458506
...this.getDefaultDeckLayerProps(opts),
459507
coverage: visConfig.coverage,
460508

461509
// color
462510
colorRange: this.getColorRange(visConfig.colorRange),
463-
colorMap: visConfig.colorRange.colorMap,
464-
colorScaleType: this.config.colorScale,
511+
colorMap,
512+
colorScaleType,
513+
...(customColorDomain ? {colorDomain: customColorDomain} : {}),
465514
upperPercentile: visConfig.percentile[1],
466515
lowerPercentile: visConfig.percentile[0],
467516
colorAggregation: visConfig.colorAggregation,

src/reducers/src/layer-utils.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,7 @@ export function mergeLayerVisConfigForNewDatasets(
136136
return layer;
137137
}
138138
const allowedKeys = (Object.keys(patch) as Array<keyof LayerVisConfig>).filter(
139-
key =>
140-
Object.prototype.hasOwnProperty.call(settings, key) && patch[key] !== undefined
139+
key => Object.prototype.hasOwnProperty.call(settings, key) && patch[key] !== undefined
141140
);
142141
if (!allowedKeys.length) {
143142
return layer;

0 commit comments

Comments
 (0)