feat: Add deck.gl Heatmap Visualization (#23551)

This commit is contained in:
Matthew Chiang 2023-05-22 02:23:07 -05:00 committed by GitHub
parent febc07aec3
commit fc8c537118
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 365 additions and 21 deletions

View File

@ -23180,7 +23180,8 @@
"version": "8.8.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz",
"integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==",
"devOptional": true,
"optional": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@ -23196,7 +23197,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"devOptional": true
"optional": true,
"peer": true
},
"node_modules/ajv-keywords": {
"version": "3.5.2",
@ -23764,6 +23766,7 @@
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/aphrodite/-/aphrodite-1.2.5.tgz",
"integrity": "sha1-g1jDbIC7A67puXFlqqcBhiJbSYM=",
"peer": true,
"dependencies": {
"asap": "^2.0.3",
"inline-style-prefixer": "^3.0.1",
@ -25363,7 +25366,8 @@
"node_modules/bowser": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz",
"integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ=="
"integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==",
"peer": true
},
"node_modules/boxen": {
"version": "5.1.2",
@ -28259,6 +28263,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz",
"integrity": "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==",
"peer": true,
"dependencies": {
"hyphenate-style-name": "^1.0.2",
"isobject": "^3.0.1"
@ -36631,7 +36636,8 @@
"node_modules/hyphenate-style-name": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==",
"peer": true
},
"node_modules/iconv-lite": {
"version": "0.4.24",
@ -37065,6 +37071,7 @@
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-3.0.8.tgz",
"integrity": "sha1-hVG45bTVcyROZqNLBPfTIHaitTQ=",
"peer": true,
"dependencies": {
"bowser": "^1.7.3",
"css-in-js-utils": "^2.0.0"
@ -54252,7 +54259,8 @@
"node_modules/string-hash": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz",
"integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs="
"integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=",
"peer": true
},
"node_modules/string-length": {
"version": "4.0.1",
@ -64788,7 +64796,6 @@
"@vx/scale": "0.0.140",
"@vx/shape": "0.0.140",
"@vx/tooltip": "0.0.140",
"aphrodite": "^1.2.0",
"d3-array": "^1.2.0",
"d3-format": "^1.2.0",
"d3-selection": "^1.1.0",
@ -80581,15 +80588,13 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"devOptional": true,
"requires": {
"ajv": "^8.0.0"
},
"requires": {},
"dependencies": {
"ajv": {
"version": "8.8.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz",
"version": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz",
"integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==",
"devOptional": true,
"optional": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@ -80601,7 +80606,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"devOptional": true
"optional": true,
"peer": true
}
}
},
@ -81032,6 +81038,7 @@
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/aphrodite/-/aphrodite-1.2.5.tgz",
"integrity": "sha1-g1jDbIC7A67puXFlqqcBhiJbSYM=",
"peer": true,
"requires": {
"asap": "^2.0.3",
"inline-style-prefixer": "^3.0.1",
@ -82267,7 +82274,8 @@
"bowser": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz",
"integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ=="
"integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==",
"peer": true
},
"boxen": {
"version": "5.1.2",
@ -84567,6 +84575,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz",
"integrity": "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==",
"peer": true,
"requires": {
"hyphenate-style-name": "^1.0.2",
"isobject": "^3.0.1"
@ -90958,7 +90967,8 @@
"hyphenate-style-name": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==",
"peer": true
},
"iconv-lite": {
"version": "0.4.24",
@ -91276,6 +91286,7 @@
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-3.0.8.tgz",
"integrity": "sha1-hVG45bTVcyROZqNLBPfTIHaitTQ=",
"peer": true,
"requires": {
"bowser": "^1.7.3",
"css-in-js-utils": "^2.0.0"
@ -101671,8 +101682,7 @@
"integrity": "sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg==",
"requires": {
"@babel/runtime": "^7.2.0",
"invariant": "^2.2.4",
"prop-types": "^15.5.7"
"invariant": "^2.2.4"
}
},
"react-split": {
@ -104476,7 +104486,8 @@
"string-hash": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz",
"integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs="
"integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=",
"peer": true
},
"string-length": {
"version": "4.0.1",
@ -108266,8 +108277,6 @@
"is-scoped": "^2.1.0",
"lodash": "^4.17.10",
"log-symbols": "^4.0.0",
"mem-fs": "^1.2.0 || ^2.0.0",
"mem-fs-editor": "^8.1.2 || ^9.0.0",
"minimatch": "^3.0.4",
"npmlog": "^5.0.1",
"p-queue": "^6.6.2",

View File

@ -218,6 +218,11 @@ const schemes = [
isDiverging: false,
colors: ['#f6EFA6', '#D88273', '#BF444C'],
},
{
id: 'deck_gl_heatmap_gradient',
label: 'Deck.gl Heatmap Default',
colors: ['#bd0026', '#f03b20', '#fd8d3c', '#feb24c', '#fed976', '#ffffb2'],
},
].map(s => new SequentialScheme(s));
export default schemes;

View File

@ -38,7 +38,7 @@ type deckGLComponentProps = {
viewport: Viewport;
width: number;
};
interface getLayerType<T> {
export interface getLayerType<T> {
(
formData: QueryFormData,
payload: JsonObject,

View File

@ -0,0 +1,86 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { HeatmapLayer } from 'deck.gl';
import React from 'react';
import { t, getSequentialSchemeRegistry } from '@superset-ui/core';
import { commonLayerProps } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { hexToRGB } from '../../utils/colors';
import { createDeckGLComponent, getLayerType } from '../../factory';
import TooltipRow from '../../TooltipRow';
function setTooltipContent(o: any) {
return (
<div className="deckgl-tooltip">
<TooltipRow
label={t('Centroid (Longitude and Latitude): ')}
value={`(${o.coordinate[0]}, ${o.coordinate[1]})`}
/>
</div>
);
}
export const getLayer: getLayerType<unknown> = (
formData,
payload,
onAddFilter,
setTooltip,
) => {
const fd = formData;
const {
intensity = 1,
radius_pixels: radiusPixels = 30,
aggregation = 'SUM',
js_data_mutator: jsFnMutator,
linear_color_scheme: colorScheme,
} = fd;
let data = payload.data.features;
if (jsFnMutator) {
// Applying user defined data mutator if defined
const jsFnMutatorFunction = sandboxedEval(fd.js_data_mutator);
data = jsFnMutatorFunction(data);
}
const colorScale = getSequentialSchemeRegistry()
?.get(colorScheme)
?.createLinearScale([0, 6]);
const colorRange = colorScale
?.range()
?.map(color => hexToRGB(color))
?.reverse();
return new HeatmapLayer({
id: `heatmp-layer-${fd.slice_id}`,
data,
intensity,
radiusPixels,
colorRange,
aggregation: aggregation.toUpperCase(),
getPosition: (d: { position: number[]; weight: number }) => d.position,
getWeight: (d: { position: number[]; weight: number }) =>
d.weight ? d.weight : 1,
...commonLayerProps(fd, setTooltip, setTooltipContent),
});
};
function getPoints(data: any[]) {
return data.map(d => d.position);
}
export default createDeckGLComponent(getLayer, getPoints);

View File

@ -0,0 +1,148 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
ControlPanelConfig,
sections,
formatSelectOptions,
} from '@superset-ui/chart-controls';
import {
t,
validateNonEmpty,
legacyValidateNumber,
legacyValidateInteger,
} from '@superset-ui/core';
import {
autozoom,
filterNulls,
jsColumns,
jsDataMutator,
jsOnclickHref,
jsTooltip,
mapboxStyle,
spatial,
viewport,
} from '../../utilities/Shared_DeckGL';
const INTENSITY_OPTIONS = Array.from(
{ length: 10 },
(_, index) => (index + 1) / 10,
);
const RADIUS_PIXEL_OPTIONS = Array.from(
{ length: 14 },
(_, index) => index * 5 + 5,
);
const config: ControlPanelConfig = {
controlPanelSections: [
sections.legacyRegularTime,
{
label: t('Query'),
expanded: true,
controlSetRows: [
[spatial],
['size'],
['row_limit'],
[filterNulls],
['adhoc_filters'],
[
{
name: 'intensity',
config: {
type: 'SelectControl',
label: t('Intesity'),
description: t(
'Intensity is the value multiplied by the weight to obtain the final weight',
),
freeForm: true,
clearable: false,
validators: [legacyValidateNumber],
default: 1,
choices: formatSelectOptions(INTENSITY_OPTIONS),
},
},
],
[
{
name: 'radius_pixels',
config: {
type: 'SelectControl',
label: t('Intensity Radius'),
description: t(
'Intensity Radius is the radius at which the weight is distributed',
),
freeForm: true,
clearable: false,
validators: [legacyValidateInteger],
default: 30,
choices: formatSelectOptions(RADIUS_PIXEL_OPTIONS),
},
},
],
],
},
{
label: t('Map'),
controlSetRows: [
[mapboxStyle, viewport],
['linear_color_scheme'],
[autozoom],
[
{
name: 'aggregation',
config: {
type: 'SelectControl',
label: t('Aggregation'),
description: t(
'The function to use when aggregating points into groups',
),
default: 'sum',
clearable: false,
renderTrigger: true,
choices: [
['sum', t('sum')],
['mean', t('mean')],
],
},
},
],
],
},
{
label: t('Advanced'),
controlSetRows: [
[jsColumns],
[jsDataMutator],
[jsTooltip],
[jsOnclickHref],
],
},
],
controlOverrides: {
size: {
label: t('Weight'),
description: t("Metric used as a weight for the grid's coloring"),
validators: [validateNonEmpty],
},
},
formDataOverrides: formData => ({
...formData,
}),
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

View File

@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import transformProps from '../../transformProps';
import controlPanel from './controlPanel';
import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
category: t('Map'),
credits: ['https://uber.github.io/deck.gl'],
description: t(
'Uses Gaussian Kernel Density Estimation to visualize spatial distribution of data',
),
name: t('deck.gl Heatmap'),
thumbnail,
useLegacyApi: true,
tags: [t('deckGL'), t('Spatial'), t('Comparison'), t('Experimental')],
});
export default class HeatmapChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./Heatmap'),
controlPanel,
metadata,
transformProps,
});
}
}

View File

@ -21,6 +21,7 @@ import ArcChartPlugin from './layers/Arc';
import GeoJsonChartPlugin from './layers/Geojson';
import GridChartPlugin from './layers/Grid';
import HexChartPlugin from './layers/Hex';
import HeatmapChartPlugin from './layers/Heatmap';
import MultiChartPlugin from './Multi';
import PathChartPlugin from './layers/Path';
import PolygonChartPlugin from './layers/Polygon';
@ -36,6 +37,7 @@ export default class DeckGLChartPreset extends Preset {
new GeoJsonChartPlugin().configure({ key: 'deck_geojson' }),
new GridChartPlugin().configure({ key: 'deck_grid' }),
new HexChartPlugin().configure({ key: 'deck_hex' }),
new HeatmapChartPlugin().configure({ key: 'deck_heatmap' }),
new MultiChartPlugin().configure({ key: 'deck_multi' }),
new PathChartPlugin().configure({ key: 'deck_path' }),
new PolygonChartPlugin().configure({ key: 'deck_polygon' }),

View File

@ -17,3 +17,31 @@
* under the License.
*/
declare module '@math.gl/web-mercator';
declare module 'deck.gl' {
import { Layer, LayerProps } from '@deck.gl/core';
interface HeatmapLayerProps<T extends object = any> extends LayerProps<T> {
id?: string;
data?: T[];
getPosition?: (d: T) => number[] | null | undefined;
getWeight?: (d: T) => number | null | undefined;
radiusPixels?: number;
colorRange?: number[][];
threshold?: number;
intensity?: number;
aggregation?: string;
}
export class HeatmapLayer<T extends object = any> extends Layer<
T,
HeatmapLayerProps<T>
> {
constructor(props: HeatmapLayerProps<T>);
}
}
declare module '*.png' {
const value: any;
export default value;
}

View File

@ -2988,6 +2988,27 @@ class DeckHex(BaseDeckGLViz):
return super().get_data(df)
class DeckHeatmap(BaseDeckGLViz):
"""deck.gl's HeatmapLayer"""
viz_type = "deck_heatmap"
verbose_name = _("Deck.gl - Heatmap")
spatial_control_keys = ["spatial"]
def get_properties(self, data: Dict[str, Any]) -> Dict[str, Any]:
return {
"position": data.get("spatial"),
"weight": (data.get(self.metric_label) if self.metric_label else None) or 1,
}
def get_data(self, df: pd.DataFrame) -> VizData:
self.metric_label = ( # pylint: disable=attribute-defined-outside-init
utils.get_metric_name(self.metric) if self.metric else None
)
return super().get_data(df)
class DeckGeoJson(BaseDeckGLViz):
"""deck.gl's GeoJSONLayer"""