diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.test.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.test.ts new file mode 100644 index 00000000000..bf3b8527df8 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.test.ts @@ -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, + }); +}); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.tsx index acf98237ac8..4ccaaaa8dd7 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.tsx @@ -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 & { 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 => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const computeGeoJsonTextOptionsFromJsOutput = ( + output: unknown, +): Partial => { + 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 => { + 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 => { + 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 => ({ + 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 = function ({ formData, onContextMenu, @@ -147,8 +257,8 @@ export const getLayer: GetLayerType = 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 = 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 = {}; + 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 = {}; + 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 = function ({ getLineWidth: fd.line_width || 1, pointRadiusScale: fd.point_radius_scale, lineWidthUnits: fd.line_width_unit, + pointType, + ...labelOpts, + ...iconOpts, ...commonLayerProps({ formData: fd, setTooltip, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts index 568659e8746..88c5a1a4189 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/controlPanel.ts @@ -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], [ { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx index f1a49107de8..a40b7734c3f 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx @@ -96,7 +96,7 @@ const jsFunctionInfo = ( ); -function jsFunctionControl( +export function jsFunctionControl( label: string, description: string, extraDescr = null, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controls.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controls.ts index 03816e96dc1..4900e7e5065 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controls.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controls.ts @@ -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,