|
| 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 | +} |
0 commit comments