src/deckgl-layers/layer-utils/cpu-aggregator.js (430 lines of code) (raw):

// Copyright (c) 2020 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. /* eslint-disable guard-for-in */ import {AGGREGATION_OPERATION} from '@deck.gl/aggregation-layers'; import {console as Console} from 'global/window'; import EnhancedBinSorter from './enhanced-bin-sorter'; import {aggregate} from 'utils/aggregate-utils'; import {AGGREGATION_TYPES, SCALE_FUNC} from 'constants/default-settings'; export const DECK_AGGREGATION_MAP = { [AGGREGATION_OPERATION.SUM]: AGGREGATION_TYPES.sum, [AGGREGATION_OPERATION.MEAN]: AGGREGATION_TYPES.average, [AGGREGATION_OPERATION.MIN]: AGGREGATION_TYPES.minimum, [AGGREGATION_OPERATION.MAX]: AGGREGATION_TYPES.maximum }; export function getValueFunc(aggregation, accessor) { if (!aggregation || !AGGREGATION_OPERATION[aggregation.toUpperCase()]) { Console.warn(`Aggregation ${aggregation} is not supported`); } const op = AGGREGATION_OPERATION[aggregation.toUpperCase()] || AGGREGATION_OPERATION.SUM; const keplerOp = DECK_AGGREGATION_MAP[op]; return pts => aggregate(pts.map(accessor), keplerOp); } export function getScaleFunctor(scaleType) { if (!scaleType || !SCALE_FUNC[scaleType]) { Console.warn(`Scale ${scaleType} is not supported`); } return SCALE_FUNC[scaleType] || SCALE_FUNC.quantize; } function nop() {} export function getGetValue(step, props, dimensionUpdater) { const {key} = dimensionUpdater; const {value, weight, aggregation} = step.triggers; let getValue = props[value.prop]; if (getValue === null) { // If `getValue` is not provided from props, build it with aggregation and weight. getValue = getValueFunc(props[aggregation.prop], props[weight.prop]); } if (getValue) { this._setDimensionState(key, {getValue}); } } export function getDimensionSortedBins(step, props, dimensionUpdater) { const {key} = dimensionUpdater; const {getValue} = this.state.dimensions[key]; const sortedBins = new EnhancedBinSorter(this.state.layerData.data || [], { getValue, filterData: props._filterData }); this._setDimensionState(key, {sortedBins}); } export function getDimensionValueDomain(step, props, dimensionUpdater) { const {key} = dimensionUpdater; const { triggers: {lowerPercentile, upperPercentile, scaleType} } = step; if (!this.state.dimensions[key].sortedBins) { // the previous step should set sortedBins, if not, something went wrong return; } // for log and sqrt scale, returns linear domain by default // TODO: support other scale function domain in bin sorter const valueDomain = this.state.dimensions[key].sortedBins.getValueDomainByScale( props[scaleType.prop], [props[lowerPercentile.prop], props[upperPercentile.prop]] ); this._setDimensionState(key, {valueDomain}); } export function getDimensionScale(step, props, dimensionUpdater) { const {key} = dimensionUpdater; const {domain, range, scaleType} = step.triggers; const {onSet} = step; if (!this.state.dimensions[key].valueDomain) { // the previous step should set valueDomain, if not, something went wrong return; } const dimensionRange = props[range.prop]; const dimensionDomain = props[domain.prop] || this.state.dimensions[key].valueDomain; const scaleFunctor = getScaleFunctor(scaleType && props[scaleType.prop])(); const scaleFunc = scaleFunctor.domain(dimensionDomain).range(dimensionRange); if (typeof onSet === 'object' && typeof props[onSet.props] === 'function') { props[onSet.props](scaleFunc.domain()); } this._setDimensionState(key, {scaleFunc}); } function normalizeResult(result = {}) { // support previous hexagonAggregator API if (result.hexagons) { return Object.assign({data: result.hexagons}, result); } else if (result.layerData) { return Object.assign({data: result.layerData}, result); } return result; } export function getAggregatedData(step, props, aggregation, aggregationParams) { const { triggers: {aggregator: aggr} } = step; const aggregator = props[aggr.prop]; // result should contain a data array and other props // result = {data: [], ...other props} const result = aggregator(props, aggregationParams); this.setState({ layerData: normalizeResult(result) }); } export const defaultAggregation = { key: 'position', updateSteps: [ { key: 'aggregate', triggers: { cellSize: { prop: 'cellSize' }, position: { prop: 'getPosition', updateTrigger: 'getPosition' }, aggregator: { prop: 'gridAggregator' } }, updater: getAggregatedData } ] }; function getSubLayerAccessor(dimensionState, dimension, layerProps) { return cell => { const {sortedBins, scaleFunc} = dimensionState; const bin = sortedBins.binMap[cell.index]; if (bin && bin.counts === 0) { // no points left in bin after filtering return dimension.nullValue; } const cv = bin && bin.value; const domain = scaleFunc.domain(); const isValueInDomain = cv >= domain[0] && cv <= domain[domain.length - 1]; // if cell value is outside domain, set alpha to 0 return isValueInDomain ? scaleFunc(cv) : dimension.nullValue; }; } export const defaultColorDimension = { key: 'fillColor', accessor: 'getFillColor', getPickingInfo: (dimensionState, cell) => { const {sortedBins} = dimensionState; const colorValue = sortedBins.binMap[cell.index] && sortedBins.binMap[cell.index].value; return {colorValue}; }, nullValue: [0, 0, 0, 0], updateSteps: [ { key: 'getValue', triggers: { value: { prop: 'getColorValue', updateTrigger: 'getColorValue' }, weight: { prop: 'getColorWeight', updateTrigger: 'getColorWeight' }, aggregation: { prop: 'colorAggregation' } }, updater: getGetValue }, { key: 'getBins', triggers: { _filterData: { prop: '_filterData', updateTrigger: '_filterData' } }, updater: getDimensionSortedBins }, { key: 'getDomain', triggers: { lowerPercentile: { prop: 'lowerPercentile' }, upperPercentile: { prop: 'upperPercentile' }, scaleType: {prop: 'colorScaleType'} }, updater: getDimensionValueDomain }, { key: 'getScaleFunc', triggers: { domain: {prop: 'colorDomain'}, range: {prop: 'colorRange'}, scaleType: {prop: 'colorScaleType'} }, onSet: { props: 'onSetColorDomain' }, updater: getDimensionScale } ], getSubLayerAccessor }; export const defaultElevationDimension = { key: 'elevation', accessor: 'getElevation', getPickingInfo: (dimensionState, cell) => { const {sortedBins} = dimensionState; const elevationValue = sortedBins.binMap[cell.index] && sortedBins.binMap[cell.index].value; return {elevationValue}; }, nullValue: -1, updateSteps: [ { key: 'getValue', triggers: { value: { prop: 'getElevationValue', updateTrigger: 'getElevationValue' }, weight: { prop: 'getElevationWeight', updateTrigger: 'getElevationWeight' }, aggregation: { prop: 'elevationAggregation' } }, updater: getGetValue }, { key: 'getBins', triggers: { _filterData: { prop: '_filterData', updateTrigger: '_filterData' } }, updater: getDimensionSortedBins }, { key: 'getDomain', triggers: { lowerPercentile: { prop: 'elevationLowerPercentile' }, upperPercentile: { prop: 'elevationUpperPercentile' }, scaleType: {prop: 'elevationScaleType'} }, updater: getDimensionValueDomain }, { key: 'getScaleFunc', triggers: { domain: {prop: 'elevationDomain'}, range: {prop: 'elevationRange'}, scaleType: {prop: 'elevationScaleType'} }, onSet: { props: 'onSetElevationDomain' }, updater: getDimensionScale } ], getSubLayerAccessor }; export const defaultDimensions = [defaultColorDimension, defaultElevationDimension]; export default class CPUAggregator { constructor(opts = {}) { this.state = { layerData: {}, dimensions: { // color: { // getValue: null, // domain: null, // sortedBins: null, // scaleFunc: nop // }, // elevation: { // getValue: null, // domain: null, // sortedBins: null, // scaleFunc: nop // } }, ...opts.initialState }; this.dimensionUpdaters = {}; this.aggregationUpdater = {}; this._addDimension(opts.dimensions || defaultDimensions); this._addAggregation(opts.aggregation || defaultAggregation); } static defaultDimensions() { return defaultDimensions; } updateAllDimensions(props) { let dimensionChanges = []; // update all dimensions for (const dim in this.dimensionUpdaters) { const updaters = this._accumulateUpdaters(0, props, this.dimensionUpdaters[dim]); dimensionChanges = dimensionChanges.concat(updaters); } dimensionChanges.forEach(f => typeof f === 'function' && f()); } updateAggregation(props, aggregationParams) { const updaters = this._accumulateUpdaters(0, props, this.aggregationUpdater); updaters.forEach(f => typeof f === 'function' && f(aggregationParams)); } updateState(opts, aggregationParams) { const {oldProps, props, changeFlags} = opts; let dimensionChanges = []; if (changeFlags.dataChanged) { // if data changed update everything this.updateAggregation(props, aggregationParams); this.updateAllDimensions(props); return this.state; } const aggregationChanges = this._getAggregationChanges(oldProps, props, changeFlags); if (aggregationChanges && aggregationChanges.length) { // get aggregatedData aggregationChanges.forEach(f => typeof f === 'function' && f(aggregationParams)); this.updateAllDimensions(props); } else { // only update dimensions dimensionChanges = this._getDimensionChanges(oldProps, props, changeFlags) || []; dimensionChanges.forEach(f => typeof f === 'function' && f()); } return this.state; } // Update private state setState(updateObject) { this.state = Object.assign({}, this.state, updateObject); } // Update private state.dimensions _setDimensionState(key, updateObject) { this.setState({ dimensions: Object.assign({}, this.state.dimensions, { [key]: Object.assign({}, this.state.dimensions[key], updateObject) }) }); } _addAggregation(aggregation) { this.aggregationUpdater = aggregation; } _addDimension(dimensions = []) { dimensions.forEach(dimension => { const {key} = dimension; this.dimensionUpdaters[key] = dimension; }); } _needUpdateStep(dimensionStep, oldProps, props, changeFlags) { // whether need to update current dimension step // dimension step is the value, domain, scaleFunction of each dimension // each step is an object with properties links to layer prop and whether the prop is // controlled by updateTriggers return Object.values(dimensionStep.triggers).some(item => { if (item.updateTrigger) { // check based on updateTriggers change first return ( changeFlags.updateTriggersChanged && (changeFlags.updateTriggersChanged.all || changeFlags.updateTriggersChanged[item.updateTrigger]) ); } // fallback to direct comparison return oldProps[item.prop] !== props[item.prop]; }); } _accumulateUpdaters(step, props, dimension) { const updaters = []; for (let i = step; i < dimension.updateSteps.length; i++) { if (typeof dimension.updateSteps[i].updater === 'function') { updaters.push( dimension.updateSteps[i].updater.bind(this, dimension.updateSteps[i], props, dimension) ); } } return updaters; } _getAllUpdaters(dimension, oldProps, props, changeFlags) { let updaters = []; const needUpdateStep = dimension.updateSteps.findIndex(step => this._needUpdateStep(step, oldProps, props, changeFlags) ); if (needUpdateStep > -1) { updaters = updaters.concat(this._accumulateUpdaters(needUpdateStep, props, dimension)); } return updaters; } _getAggregationChanges(oldProps, props, changeFlags) { const updaters = this._getAllUpdaters(this.aggregationUpdater, oldProps, props, changeFlags); return updaters.length ? updaters : null; } _getDimensionChanges(oldProps, props, changeFlags) { let updaters = []; // get dimension to be updated for (const key in this.dimensionUpdaters) { // return the first triggered updater for each dimension const dimension = this.dimensionUpdaters[key]; const dimensionUpdaters = this._getAllUpdaters(dimension, oldProps, props, changeFlags); updaters = updaters.concat(dimensionUpdaters); } return updaters.length ? updaters : null; } getUpdateTriggers(props) { const _updateTriggers = props.updateTriggers || {}; const updateTriggers = {}; for (const key in this.dimensionUpdaters) { const {accessor, updateSteps} = this.dimensionUpdaters[key]; // fold dimension triggers into each accessor updateTriggers[accessor] = {}; updateSteps.forEach(step => { Object.values(step.triggers || []).forEach(({prop, updateTrigger}) => { if (updateTrigger) { // if prop is based on updateTrigger e.g. getColorValue, getColorWeight // and updateTriggers is passed in from layer prop // fold the updateTriggers into accessor const fromProp = _updateTriggers[updateTrigger]; if (typeof fromProp === 'object' && !Array.isArray(fromProp)) { // if updateTrigger is an object spread it Object.assign(updateTriggers[accessor], fromProp); } else if (fromProp !== undefined) { updateTriggers[accessor][prop] = fromProp; } } else { // if prop is not based on updateTrigger updateTriggers[accessor][prop] = props[prop]; } }); }); } return updateTriggers; } getPickingInfo({info}, layerProps) { const isPicked = info.picked && info.index > -1; let object = null; if (isPicked) { const cell = this.state.layerData.data[info.index]; let binInfo = {}; for (const key in this.dimensionUpdaters) { const {getPickingInfo} = this.dimensionUpdaters[key]; if (typeof getPickingInfo === 'function') { binInfo = Object.assign( {}, binInfo, getPickingInfo(this.state.dimensions[key], cell, layerProps) ); } } object = Object.assign(binInfo, cell, { points: cell.filteredPoints || cell.points }); } // add bin and to info return Object.assign(info, { picked: Boolean(object), // override object with picked cell object }); } getAccessor(dimensionKey, layerProps) { if (!this.dimensionUpdaters.hasOwnProperty(dimensionKey)) { return nop; } return this.dimensionUpdaters[dimensionKey].getSubLayerAccessor( this.state.dimensions[dimensionKey], this.dimensionUpdaters[dimensionKey], layerProps ); } } CPUAggregator.getDimensionScale = getDimensionScale;