feat(chart): support icons and text in the deck.gl Geojson visualization (#36201)

Co-authored-by: Joshua Daniel <jdaniel@gflenv.com>
This commit is contained in:
Joshua Daniel
2025-12-16 17:28:04 -05:00
committed by Beto Dealmeida
parent f342ddcd98
commit 38fe9bcf42
5 changed files with 535 additions and 5 deletions

View File

@@ -0,0 +1,121 @@
/**
* 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 { SqlaFormData } from '@superset-ui/core';
import {
computeGeoJsonTextOptionsFromJsOutput,
computeGeoJsonTextOptionsFromFormData,
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
} from './Geojson';
jest.mock('@deck.gl/react', () => ({
__esModule: true,
default: () => null,
}));
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonTextOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonTextOptionsFromJsOutput extracts valid text options from the input object', () => {
const input = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
invalidOption: true,
};
const expectedOutput = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
};
expect(computeGeoJsonTextOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
const formData: SqlaFormData = {
label_property_name: 'name',
label_color: { r: 1, g: 2, b: 3, a: 1 },
label_size: 123,
label_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getText: expect.any(Function),
getTextColor: [1, 2, 3, 255],
getTextSize: 123,
textSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonTextOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
const sampleFeature = { properties: { name: 'Test' } };
expect(actualOutput.getText(sampleFeature)).toBe('Test');
});
test('computeGeoJsonIconOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonIconOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonIconOptionsFromJsOutput extracts valid icon options from the input object', () => {
const input = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
invalidOption: false,
};
const expectedOutput = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
};
expect(computeGeoJsonIconOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonIconOptionsFromFormData computes icon options based on form data', () => {
const formData: SqlaFormData = {
icon_url: 'https://example.com/icon.png',
icon_size: 123,
icon_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getIcon: expect.any(Function),
getIconSize: 123,
iconSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonIconOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
expect(actualOutput.getIcon()).toEqual({
url: 'https://example.com/icon.png',
height: 128,
width: 128,
});
});

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { memo, useCallback, useMemo, useRef } from 'react';
import { GeoJsonLayer } from '@deck.gl/layers';
import { GeoJsonLayer, GeoJsonLayerProps } from '@deck.gl/layers';
// ignoring the eslint error below since typescript prefers 'geojson' to '@types/geojson'
// eslint-disable-next-line import/no-unresolved
import { Feature, Geometry, GeoJsonProperties } from 'geojson';
@@ -29,6 +29,7 @@ import {
JsonValue,
QueryFormData,
SetDataMaskHook,
SqlaFormData,
} from '@superset-ui/core';
import {
@@ -44,6 +45,7 @@ import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types';
import { GetLayerType } from '../../factory';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
import { BLACK_COLOR, PRIMARY_COLOR } from '../../utilities/controls';
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
properties: JsonObject;
@@ -137,6 +139,114 @@ const getFillColor = (feature: JsonObject, filterStateValue: unknown[]) => {
};
const getLineColor = (feature: JsonObject) => feature?.properties?.strokeColor;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export const computeGeoJsonTextOptionsFromJsOutput = (
output: unknown,
): Partial<GeoJsonLayerProps> => {
if (!isObject(output)) return {};
// Properties sourced from:
// https://deck.gl/docs/api-reference/layers/geojson-layer#pointtype-options-2
const options: (keyof GeoJsonLayerProps)[] = [
'getText',
'getTextColor',
'getTextAngle',
'getTextSize',
'getTextAnchor',
'getTextAlignmentBaseline',
'getTextPixelOffset',
'getTextBackgroundColor',
'getTextBorderColor',
'getTextBorderWidth',
'textSizeUnits',
'textSizeScale',
'textSizeMinPixels',
'textSizeMaxPixels',
'textCharacterSet',
'textFontFamily',
'textFontWeight',
'textLineHeight',
'textMaxWidth',
'textWordBreak',
'textBackground',
'textBackgroundPadding',
'textOutlineColor',
'textOutlineWidth',
'textBillboard',
'textFontSettings',
];
const allEntries = Object.entries(output);
const validEntries = allEntries.filter(([k]) =>
options.includes(k as keyof GeoJsonLayerProps),
);
return Object.fromEntries(validEntries);
};
export const computeGeoJsonTextOptionsFromFormData = (
fd: SqlaFormData,
): Partial<GeoJsonLayerProps> => {
const lc = fd.label_color ?? BLACK_COLOR;
return {
getText: (f: JsonObject) => f?.properties?.[fd.label_property_name],
getTextColor: [lc.r, lc.g, lc.b, 255 * lc.a],
getTextSize: parseInt(fd.label_size, 10),
textSizeUnits: fd.label_size_unit,
};
};
export const computeGeoJsonIconOptionsFromJsOutput = (
output: unknown,
): Partial<GeoJsonLayerProps> => {
if (!isObject(output)) return {};
// Properties sourced from:
// https://deck.gl/docs/api-reference/layers/geojson-layer#pointtype-options-1
const options: (keyof GeoJsonLayerProps)[] = [
'getIcon',
'getIconSize',
'getIconColor',
'getIconAngle',
'getIconPixelOffset',
'iconSizeUnits',
'iconSizeScale',
'iconSizeMinPixels',
'iconSizeMaxPixels',
'iconAtlas',
'iconMapping',
'iconBillboard',
'iconAlphaCutoff',
];
const allEntries = Object.entries(output);
const validEntries = allEntries.filter(([k]) =>
options.includes(k as keyof GeoJsonLayerProps),
);
return Object.fromEntries(validEntries);
};
export const computeGeoJsonIconOptionsFromFormData = (
fd: SqlaFormData,
): Partial<GeoJsonLayerProps> => ({
getIcon: fd.icon_url
? () => ({
url: fd.icon_url,
// This is the size deck.gl resizes the icon internally while preserving
// its aspect ratio. This is not the actual size the icon is rendered at,
// which is instead controlled by getIconSize below. These are set because
// deck.gl requires it, and 128x128 is a reasonable default. Read more at:
// https://deck.gl/docs/api-reference/layers/icon-layer#geticon
width: 128,
height: 128,
})
: undefined,
getIconSize: parseInt(fd.icon_size, 10),
iconSizeUnits: fd.icon_size_unit,
});
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
formData,
onContextMenu,
@@ -147,8 +257,8 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
emitCrossFilters,
}) {
const fd = formData;
const fc = fd.fill_color_picker;
const sc = fd.stroke_color_picker;
const fc = fd.fill_color_picker ?? PRIMARY_COLOR;
const sc = fd.stroke_color_picker ?? PRIMARY_COLOR;
const fillColor = [fc.r, fc.g, fc.b, 255 * fc.a];
const strokeColor = [sc.r, sc.g, sc.b, 255 * sc.a];
const propOverrides: JsonObject = {};
@@ -169,6 +279,38 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
processedFeatures = jsFnMutator(features) as ProcessedFeature[];
}
let pointType = 'circle';
if (fd.enable_labels) {
pointType = `${pointType}+text`;
}
if (fd.enable_icons) {
pointType = `${pointType}+icon`;
}
let labelOpts: Partial<GeoJsonLayerProps> = {};
if (fd.enable_labels) {
if (fd.enable_label_javascript_mode) {
const generator = sandboxedEval(fd.label_javascript_config_generator);
if (typeof generator === 'function') {
labelOpts = computeGeoJsonTextOptionsFromJsOutput(generator());
}
} else {
labelOpts = computeGeoJsonTextOptionsFromFormData(fd);
}
}
let iconOpts: Partial<GeoJsonLayerProps> = {};
if (fd.enable_icons) {
if (fd.enable_icon_javascript_mode) {
const generator = sandboxedEval(fd.icon_javascript_config_generator);
if (typeof generator === 'function') {
iconOpts = computeGeoJsonIconOptionsFromJsOutput(generator());
}
} else {
iconOpts = computeGeoJsonIconOptionsFromFormData(fd);
}
}
return new GeoJsonLayer({
id: `geojson-layer-${fd.slice_id}` as const,
data: processedFeatures,
@@ -181,6 +323,9 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
getLineWidth: fd.line_width || 1,
pointRadiusScale: fd.point_radius_scale,
lineWidthUnits: fd.line_width_unit,
pointType,
...labelOpts,
...iconOpts,
...commonLayerProps({
formData: fd,
setTooltip,

View File

@@ -17,7 +17,12 @@
* under the License.
*/
import { ControlPanelConfig } from '@superset-ui/chart-controls';
import { t, legacyValidateInteger } from '@superset-ui/core';
import {
t,
legacyValidateInteger,
isFeatureEnabled,
FeatureFlag,
} from '@superset-ui/core';
import { formatSelectOptions } from '../../utilities/utils';
import {
filterNulls,
@@ -36,8 +41,27 @@ import {
lineWidth,
tooltipContents,
tooltipTemplate,
jsFunctionControl,
} from '../../utilities/Shared_DeckGL';
import { dndGeojsonColumn } from '../../utilities/sharedDndControls';
import { BLACK_COLOR } from '../../utilities/controls';
const defaultLabelConfigGenerator = `() => ({
// Check the documentation at:
// https://deck.gl/docs/api-reference/layers/geojson-layer#pointtype-options-2
getText: f => f.properties.name,
getTextColor: [0, 0, 0, 255],
getTextSize: 24,
textSizeUnits: 'pixels',
})`;
const defaultIconConfigGenerator = `() => ({
// Check the documentation at:
// https://deck.gl/docs/api-reference/layers/geojson-layer#pointtype-options-1
getIcon: () => ({ url: '', height: 128, width: 128 }),
getIconSize: 32,
iconSizeUnits: 'pixels',
})`;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -63,6 +87,245 @@ const config: ControlPanelConfig = {
[fillColorPicker, strokeColorPicker],
[filled, stroked],
[extruded],
[
{
name: 'enable_labels',
config: {
type: 'CheckboxControl',
label: t('Enable labels'),
description: t('Enables rendering of labels for GeoJSON points'),
default: false,
renderTrigger: true,
},
},
],
[
{
name: 'enable_label_javascript_mode',
config: {
type: 'CheckboxControl',
label: t('Enable label JavaScript mode'),
description: t(
'Enables custom label configuration via JavaScript',
),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
isFeatureEnabled(FeatureFlag.EnableJavascriptControls),
default: false,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_property_name',
config: {
type: 'TextControl',
label: t('Label property name'),
description: t('The feature property to use for point labels'),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
(!form_data.enable_label_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
default: 'name',
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_color',
config: {
type: 'ColorPickerControl',
label: t('Label color'),
description: t('The color of the point labels'),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
(!form_data.enable_label_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
default: BLACK_COLOR,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_size',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Label size'),
description: t('The font size of the point labels'),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
(!form_data.enable_label_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
validators: [legacyValidateInteger],
choices: formatSelectOptions([8, 16, 24, 32, 64, 128]),
default: 24,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_size_unit',
config: {
type: 'SelectControl',
label: t('Label size unit'),
description: t('The unit for label size'),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
(!form_data.enable_label_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
choices: [
['meters', t('Meters')],
['pixels', t('Pixels')],
],
default: 'pixels',
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_javascript_config_generator',
config: {
...jsFunctionControl(
t('Label JavaScript config generator'),
t(
'A JavaScript function that generates a label configuration object',
),
undefined,
undefined,
defaultLabelConfigGenerator,
),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
!!form_data.enable_label_javascript_mode &&
isFeatureEnabled(FeatureFlag.EnableJavascriptControls),
resetOnHide: false,
},
},
],
[
{
name: 'enable_icons',
config: {
type: 'CheckboxControl',
label: t('Enable icons'),
description: t('Enables rendering of icons for GeoJSON points'),
default: false,
renderTrigger: true,
},
},
],
[
{
name: 'enable_icon_javascript_mode',
config: {
type: 'CheckboxControl',
label: t('Enable icon JavaScript mode'),
description: t(
'Enables custom icon configuration via JavaScript',
),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
isFeatureEnabled(FeatureFlag.EnableJavascriptControls),
default: false,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'icon_url',
config: {
type: 'TextControl',
label: t('Icon URL'),
description: t(
'The image URL of the icon to display for GeoJSON points. ' +
'Note that the image URL must conform to the content ' +
'security policy (CSP) in order to load correctly.',
),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
(!form_data.enable_icon_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
default: '',
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'icon_size',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Icon size'),
description: t('The size of the point icons'),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
(!form_data.enable_icon_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
validators: [legacyValidateInteger],
choices: formatSelectOptions([16, 24, 32, 64, 128]),
default: 32,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'icon_size_unit',
config: {
type: 'SelectControl',
label: t('Icon size unit'),
description: t('The unit for icon size'),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
(!form_data.enable_icon_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
choices: [
['meters', t('Meters')],
['pixels', t('Pixels')],
],
default: 'pixels',
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'icon_javascript_config_generator',
config: {
...jsFunctionControl(
t('Icon JavaScript config generator'),
t(
'A JavaScript function that generates an icon configuration object',
),
undefined,
undefined,
defaultIconConfigGenerator,
),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
!!form_data.enable_icon_javascript_mode &&
isFeatureEnabled(FeatureFlag.EnableJavascriptControls),
resetOnHide: false,
},
},
],
[lineWidth],
[
{

View File

@@ -96,7 +96,7 @@ const jsFunctionInfo = (
</div>
);
function jsFunctionControl(
export function jsFunctionControl(
label: string,
description: string,
extraDescr = null,

View File

@@ -39,6 +39,7 @@ export function columnChoices(datasource: Dataset | QueryResponse | null) {
}
export const PRIMARY_COLOR = { r: 0, g: 122, b: 135, a: 1 };
export const BLACK_COLOR = { r: 0, g: 0, b: 0, a: 1 };
export default {
default: null,