kepler.gl
Version:
kepler.gl is a webgl based application to visualize large scale location data in the browser
228 lines (200 loc) • 6.5 kB
JavaScript
// 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.
import {createSelector} from 'reselect';
import memoize from 'lodash.memoize';
import {CHANNEL_SCALES, SCALE_FUNC, ALL_FIELD_TYPES} from 'constants/default-settings';
import {hexToRgb} from 'utils/color-utils';
import MapboxGLLayer from '../mapboxgl-layer';
import HeatmapLayerIcon from './heatmap-layer-icon';
export const MAX_ZOOM_LEVEL = 18;
export const pointPosAccessor = ({lat, lng}) => d => [
// lng
d[lng.fieldIdx],
// lat
d[lat.fieldIdx]
];
export const pointColResolver = ({lat, lng}) => `${lat.fieldIdx}-${lng.fieldIdx}`;
export const heatmapVisConfigs = {
opacity: 'opacity',
colorRange: 'colorRange',
radius: 'heatmapRadius'
};
/**
*
* @param {Object} colorRange
* @return {Array} [
* 0, "rgba(33,102,172,0)",
* 0.2, "rgb(103,169,207)",
* 0.4, "rgb(209,229,240)",
* 0.6, "rgb(253,219,199)",
* 0.8, "rgb(239,138,98)",
* 1, "rgb(178,24,43)"
* ]
*/
const heatmapDensity = colorRange => {
const scaleFunction = SCALE_FUNC.quantize;
const colors = ['#000000', ...colorRange.colors];
const scale = scaleFunction()
.domain([0, 1])
.range(colors);
const colorDensity = scale.range().reduce((bands, level) => {
const invert = scale.invertExtent(level);
return [
...bands,
invert[0], // first value in the range
`rgb(${hexToRgb(level).join(',')})` // color
];
}, []);
colorDensity[1] = 'rgba(0,0,0,0)';
return colorDensity;
};
class HeatmapLayer extends MapboxGLLayer {
constructor(props) {
super(props);
this.registerVisConfig(heatmapVisConfigs);
this.getPosition = memoize(pointPosAccessor, pointColResolver);
}
get type() {
return 'heatmap';
}
get visualChannels() {
return {
weight: {
property: 'weight',
field: 'weightField',
scale: 'weightScale',
domain: 'weightDomain',
key: 'weight',
// supportedFieldTypes can be determined by channelScaleType
// or specified here
defaultMeasure: 'property.density',
supportedFieldTypes: [ALL_FIELD_TYPES.real, ALL_FIELD_TYPES.integer],
channelScaleType: CHANNEL_SCALES.size
}
};
}
get layerIcon() {
return HeatmapLayerIcon;
}
getVisualChannelDescription(channel) {
return channel === 'color'
? {
label: 'property.color',
measure: 'property.density'
}
: {
label: 'property.weight',
measure: this.config.weightField ? this.config.weightField.name : 'property.density'
};
}
getDefaultLayerConfig(props = {}) {
// mapbox heatmap layer color is always based on density
// no need to set colorField, colorDomain and colorScale
/* eslint-disable no-unused-vars */
const {colorField, colorDomain, colorScale, ...layerConfig} = {
...super.getDefaultLayerConfig(props),
weightField: null,
weightDomain: [0, 1],
weightScale: 'linear'
};
/* eslint-enable no-unused-vars */
return layerConfig;
}
getPositionAccessor() {
return this.getPosition(this.config.columns);
}
updateLayerMeta(allData) {
const getPosition = this.getPositionAccessor();
const bounds = this.getPointsBounds(allData, d => getPosition(d));
this.updateMeta({bounds});
}
columnsSelector = config => pointColResolver(config.columns);
visConfigSelector = config => config.visConfig;
weightFieldSelector = config => (config.weightField ? config.weightField.name : null);
weightDomainSelector = config => config.weightDomain;
paintSelector = createSelector(
this.visConfigSelector,
this.weightFieldSelector,
this.weightDomainSelector,
(visConfig, weightField, weightDomain) => ({
'heatmap-weight': weightField
? ['interpolate', ['linear'], ['get', weightField], weightDomain[0], 0, weightDomain[1], 1]
: 1,
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, MAX_ZOOM_LEVEL, 3],
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
...heatmapDensity(visConfig.colorRange)
],
'heatmap-radius': [
'interpolate',
['linear'],
['zoom'],
0,
2,
MAX_ZOOM_LEVEL,
visConfig.radius // radius
],
'heatmap-opacity': visConfig.opacity
})
);
computeHeatmapConfiguration = createSelector(
this.sourceSelector,
this.filterSelector,
this.paintSelector,
(source, filter, paint) => {
return {
type: 'heatmap',
id: this.id,
source,
layout: {
visibility: 'visible'
},
maxzoom: MAX_ZOOM_LEVEL,
paint,
...(this.isValidFilter(filter) ? {filter} : {})
};
}
);
getGeometry(position) {
return position.every(Number.isFinite)
? {
type: 'Point',
coordinates: position
}
: null;
}
formatLayerData(datasets, oldLayerData) {
const {weightField} = this.config;
const getPosition = this.getPositionAccessor();
const {data} = this.updateData(datasets, oldLayerData);
const newConfig = this.computeHeatmapConfiguration(this.config, datasets);
newConfig.id = this.id;
return {
columns: this.config.columns,
config: newConfig,
data,
weightField,
getPosition
};
}
}
export default HeatmapLayer;