diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx index 1757a7b77e5..1400a151334 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx @@ -169,12 +169,12 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => { })); } case COLOR_SCHEME_TYPES.color_breakpoints: { - const defaultBreakpointColor = fd.deafult_breakpoint_color + const defaultBreakpointColor = fd.default_breakpoint_color ? [ - fd.deafult_breakpoint_color.r, - fd.deafult_breakpoint_color.g, - fd.deafult_breakpoint_color.b, - fd.deafult_breakpoint_color.a * 255, + fd.default_breakpoint_color.r, + fd.default_breakpoint_color.g, + fd.default_breakpoint_color.b, + fd.default_breakpoint_color.a * 255, ] : [ DEFAULT_DECKGL_COLOR.r, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/buildQuery.ts new file mode 100644 index 00000000000..6e5a714d12d --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/buildQuery.ts @@ -0,0 +1,96 @@ +/** + * 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 { + buildQueryContext, + ensureIsArray, + SqlaFormData, +} from '@superset-ui/core'; +import { + getSpatialColumns, + addSpatialNullFilters, + SpatialFormData, +} from '../spatialUtils'; +import { addTooltipColumnsToQuery } from '../buildQueryUtils'; + +export interface DeckArcFormData extends SqlaFormData { + start_spatial: SpatialFormData['spatial']; + end_spatial: SpatialFormData['spatial']; + dimension?: string; + js_columns?: string[]; + tooltip_contents?: unknown[]; + tooltip_template?: string; +} + +export default function buildQuery(formData: DeckArcFormData) { + const { + start_spatial, + end_spatial, + dimension, + js_columns, + tooltip_contents, + } = formData; + + if (!start_spatial || !end_spatial) { + throw new Error( + 'Start and end spatial configurations are required for Arc charts', + ); + } + + return buildQueryContext(formData, baseQueryObject => { + const startSpatialColumns = getSpatialColumns(start_spatial); + const endSpatialColumns = getSpatialColumns(end_spatial); + + let columns = [ + ...(baseQueryObject.columns || []), + ...startSpatialColumns, + ...endSpatialColumns, + ]; + + if (dimension) { + columns = [...columns, dimension]; + } + + const jsColumns = ensureIsArray(js_columns || []); + jsColumns.forEach(col => { + if (!columns.includes(col)) { + columns.push(col); + } + }); + + columns = addTooltipColumnsToQuery(columns, tooltip_contents); + + let filters = addSpatialNullFilters( + start_spatial, + ensureIsArray(baseQueryObject.filters || []), + ); + filters = addSpatialNullFilters(end_spatial, filters); + + const isTimeseries = !!formData.time_grain_sqla; + + return [ + { + ...baseQueryObject, + columns, + filters, + is_timeseries: isTimeseries, + row_limit: baseQueryObject.row_limit, + }, + ]; + }); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/index.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/index.ts index 60a2c1db07d..364e57c469c 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/index.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/index.ts @@ -21,7 +21,8 @@ import thumbnail from './images/thumbnail.png'; import thumbnailDark from './images/thumbnail-dark.png'; import example from './images/example.png'; import exampleDark from './images/example-dark.png'; -import transformProps from '../../transformProps'; +import transformProps from './transformProps'; +import buildQuery from './buildQuery'; import controlPanel from './controlPanel'; const metadata = new ChartMetadata({ @@ -39,13 +40,13 @@ const metadata = new ChartMetadata({ thumbnail, thumbnailDark, exampleGallery: [{ url: example, urlDark: exampleDark }], - useLegacyApi: true, tags: [t('deckGL'), t('Geo'), t('3D'), t('Relational'), t('Web')], }); export default class ArcChartPlugin extends ChartPlugin { constructor() { super({ + buildQuery, loadChart: () => import('./Arc'), controlPanel, metadata, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/transformProps.ts new file mode 100644 index 00000000000..85df90585d9 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Arc/transformProps.ts @@ -0,0 +1,108 @@ +/** + * 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 { ChartProps } from '@superset-ui/core'; +import { + processSpatialData, + addJsColumnsToExtraProps, + DataRecord, +} from '../spatialUtils'; +import { + createBaseTransformResult, + getRecordsFromQuery, + addPropertiesToFeature, +} from '../transformUtils'; +import { DeckArcFormData } from './buildQuery'; + +interface ArcPoint { + sourcePosition: [number, number]; + targetPosition: [number, number]; + cat_color?: string; + __timestamp?: number; + extraProps?: Record; + [key: string]: unknown; +} + +function processArcData( + records: DataRecord[], + startSpatial: DeckArcFormData['start_spatial'], + endSpatial: DeckArcFormData['end_spatial'], + dimension?: string, + jsColumns?: string[], +): ArcPoint[] { + if (!startSpatial || !endSpatial || !records.length) { + return []; + } + + const startFeatures = processSpatialData(records, startSpatial); + const endFeatures = processSpatialData(records, endSpatial); + const excludeKeys = new Set( + ['__timestamp', dimension, ...(jsColumns || [])].filter( + (key): key is string => key != null, + ), + ); + + return records + .map((record, index) => { + const startFeature = startFeatures[index]; + const endFeature = endFeatures[index]; + + if (!startFeature || !endFeature) { + return null; + } + + let arcPoint: ArcPoint = { + sourcePosition: startFeature.position, + targetPosition: endFeature.position, + extraProps: {}, + }; + + arcPoint = addJsColumnsToExtraProps(arcPoint, record, jsColumns); + + if (dimension && record[dimension] != null) { + arcPoint.cat_color = String(record[dimension]); + } + + // eslint-disable-next-line no-underscore-dangle + if (record.__timestamp != null) { + // eslint-disable-next-line no-underscore-dangle + arcPoint.__timestamp = Number(record.__timestamp); + } + + arcPoint = addPropertiesToFeature(arcPoint, record, excludeKeys); + return arcPoint; + }) + .filter((point): point is ArcPoint => point !== null); +} + +export default function transformProps(chartProps: ChartProps) { + const { rawFormData: formData } = chartProps; + const { start_spatial, end_spatial, dimension, js_columns } = + formData as DeckArcFormData; + + const records = getRecordsFromQuery(chartProps.queriesData); + const features = processArcData( + records, + start_spatial, + end_spatial, + dimension, + js_columns, + ); + + return createBaseTransformResult(chartProps, features); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/buildQuery.ts new file mode 100644 index 00000000000..294a0f997bf --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/buildQuery.ts @@ -0,0 +1,34 @@ +/** + * 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 { SpatialFormData, buildSpatialQuery } from '../spatialUtils'; + +export interface DeckContourFormData extends SpatialFormData { + cellSize?: string; + aggregation?: string; + contours?: Array<{ + color: { r: number; g: number; b: number }; + lowerThreshold: number; + upperThreshold?: number; + strokeWidth?: number; + }>; +} + +export default function buildQuery(formData: DeckContourFormData) { + return buildSpatialQuery(formData); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/index.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/index.ts index 60b0f122fb0..7d220b1ac02 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/index.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/index.ts @@ -17,12 +17,13 @@ * under the License. */ import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; -import transformProps from '../../transformProps'; -import controlPanel from './controlPanel'; import thumbnail from './images/thumbnail.png'; import thumbnailDark from './images/thumbnail-dark.png'; import example from './images/example.png'; import exampleDark from './images/example-dark.png'; +import buildQuery from './buildQuery'; +import transformProps from './transformProps'; +import controlPanel from './controlPanel'; const metadata = new ChartMetadata({ category: t('Map'), @@ -34,7 +35,6 @@ const metadata = new ChartMetadata({ name: t('deck.gl Contour'), thumbnail, thumbnailDark, - useLegacyApi: true, tags: [t('deckGL'), t('Spatial'), t('Comparison')], behaviors: [Behavior.InteractiveChart], }); @@ -42,6 +42,7 @@ const metadata = new ChartMetadata({ export default class ContourChartPlugin extends ChartPlugin { constructor() { super({ + buildQuery, loadChart: () => import('./Contour'), controlPanel, metadata, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/transformProps.ts new file mode 100644 index 00000000000..1ca8144242c --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/transformProps.ts @@ -0,0 +1,21 @@ +/** + * 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 { transformSpatialProps } from '../spatialUtils'; + +export default transformSpatialProps; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/Grid.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/Grid.tsx index da1367a189f..72faceb32a8 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/Grid.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/Grid.tsx @@ -76,7 +76,7 @@ export const getLayer: GetLayerType = function ({ const colorSchemeType = fd.color_scheme_type; const colorRange = getColorRange({ - defaultBreakpointsColor: fd.deafult_breakpoint_color, + defaultBreakpointsColor: fd.default_breakpoint_color, colorSchemeType, colorScale, colorBreakpoints, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/buildQuery.ts new file mode 100644 index 00000000000..fdd73b19649 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/buildQuery.ts @@ -0,0 +1,27 @@ +/** + * 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 { SpatialFormData, buildSpatialQuery } from '../spatialUtils'; + +export interface DeckGridFormData extends SpatialFormData { + extruded?: boolean; +} + +export default function buildQuery(formData: DeckGridFormData) { + return buildSpatialQuery(formData); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/index.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/index.ts index 7570121deba..18144934aca 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/index.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/index.ts @@ -21,7 +21,8 @@ import thumbnail from './images/thumbnail.png'; import thumbnailDark from './images/thumbnail-dark.png'; import example from './images/example.png'; import exampleDark from './images/example-dark.png'; -import transformProps from '../../transformProps'; +import buildQuery from './buildQuery'; +import transformProps from './transformProps'; import controlPanel from './controlPanel'; const metadata = new ChartMetadata({ @@ -34,7 +35,6 @@ const metadata = new ChartMetadata({ thumbnail, thumbnailDark, exampleGallery: [{ url: example, urlDark: exampleDark }], - useLegacyApi: true, tags: [t('deckGL'), t('3D'), t('Comparison')], behaviors: [Behavior.InteractiveChart], }); @@ -42,6 +42,7 @@ const metadata = new ChartMetadata({ export default class GridChartPlugin extends ChartPlugin { constructor() { super({ + buildQuery, loadChart: () => import('./Grid'), controlPanel, metadata, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/transformProps.ts new file mode 100644 index 00000000000..4b8f437d7fc --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Grid/transformProps.ts @@ -0,0 +1,24 @@ +/** + * 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 { ChartProps } from '@superset-ui/core'; +import { transformSpatialProps } from '../spatialUtils'; + +export default function transformProps(chartProps: ChartProps) { + return transformSpatialProps(chartProps); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/Heatmap.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/Heatmap.tsx index 68bdfac85d6..03e163ea000 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/Heatmap.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/Heatmap.tsx @@ -126,7 +126,7 @@ export const getLayer: GetLayerType = ({ const colorSchemeType = fd.color_scheme_type; const colorRange = getColorRange({ - defaultBreakpointsColor: fd.deafult_breakpoint_color, + defaultBreakpointsColor: fd.default_breakpoint_color, colorBreakpoints: fd.color_breakpoints, fixedColor: fd.color_picker, colorSchemeType, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/buildQuery.ts new file mode 100644 index 00000000000..94607704acf --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/buildQuery.ts @@ -0,0 +1,23 @@ +/** + * 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 { SpatialFormData, buildSpatialQuery } from '../spatialUtils'; + +export default function buildQuery(formData: SpatialFormData) { + return buildSpatialQuery(formData); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/index.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/index.ts index 418e08daa41..23fc2ad58a8 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/index.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/index.ts @@ -17,12 +17,13 @@ * under the License. */ import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; -import transformProps from '../../transformProps'; -import controlPanel from './controlPanel'; import thumbnail from './images/thumbnail.png'; import thumbnailDark from './images/thumbnail-dark.png'; import example from './images/example.png'; import exampleDark from './images/example-dark.png'; +import buildQuery from './buildQuery'; +import transformProps from './transformProps'; +import controlPanel from './controlPanel'; const metadata = new ChartMetadata({ category: t('Map'), @@ -34,7 +35,6 @@ const metadata = new ChartMetadata({ name: t('deck.gl Heatmap'), thumbnail, thumbnailDark, - useLegacyApi: true, tags: [t('deckGL'), t('Spatial'), t('Comparison')], behaviors: [Behavior.InteractiveChart], }); @@ -42,6 +42,7 @@ const metadata = new ChartMetadata({ export default class HeatmapChartPlugin extends ChartPlugin { constructor() { super({ + buildQuery, loadChart: () => import('./Heatmap'), controlPanel, metadata, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/transformProps.ts new file mode 100644 index 00000000000..4b8f437d7fc --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Heatmap/transformProps.ts @@ -0,0 +1,24 @@ +/** + * 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 { ChartProps } from '@superset-ui/core'; +import { transformSpatialProps } from '../spatialUtils'; + +export default function transformProps(chartProps: ChartProps) { + return transformSpatialProps(chartProps); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/Hex.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/Hex.tsx index 9377ee75b18..1f1e35f3dc9 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/Hex.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/Hex.tsx @@ -75,7 +75,7 @@ export const getLayer: GetLayerType = function ({ const colorSchemeType = fd.color_scheme_type; const colorRange = getColorRange({ - defaultBreakpointsColor: fd.deafult_breakpoint_color, + defaultBreakpointsColor: fd.default_breakpoint_color, colorBreakpoints: fd.color_breakpoints, fixedColor: fd.color_picker, colorSchemeType, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/buildQuery.ts new file mode 100644 index 00000000000..d5b9a56a131 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/buildQuery.ts @@ -0,0 +1,29 @@ +/** + * 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 { SpatialFormData, buildSpatialQuery } from '../spatialUtils'; + +export interface DeckHexFormData extends SpatialFormData { + extruded?: boolean; + js_agg_function?: string; + grid_size?: number; +} + +export default function buildQuery(formData: DeckHexFormData) { + return buildSpatialQuery(formData); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/index.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/index.ts index 2712e847db5..fda3cff9b40 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/index.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/index.ts @@ -21,7 +21,8 @@ import thumbnail from './images/thumbnail.png'; import thumbnailDark from './images/thumbnail-dark.png'; import example from './images/example.png'; import exampleDark from './images/example-dark.png'; -import transformProps from '../../transformProps'; +import buildQuery from './buildQuery'; +import transformProps from './transformProps'; import controlPanel from './controlPanel'; const metadata = new ChartMetadata({ @@ -34,7 +35,6 @@ const metadata = new ChartMetadata({ name: t('deck.gl 3D Hexagon'), thumbnail, thumbnailDark, - useLegacyApi: true, tags: [t('deckGL'), t('3D'), t('Geo'), t('Comparison')], behaviors: [Behavior.InteractiveChart], }); @@ -42,6 +42,7 @@ const metadata = new ChartMetadata({ export default class HexChartPlugin extends ChartPlugin { constructor() { super({ + buildQuery, loadChart: () => import('./Hex'), controlPanel, metadata, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/transformProps.ts new file mode 100644 index 00000000000..4b8f437d7fc --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Hex/transformProps.ts @@ -0,0 +1,24 @@ +/** + * 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 { ChartProps } from '@superset-ui/core'; +import { transformSpatialProps } from '../spatialUtils'; + +export default function transformProps(chartProps: ChartProps) { + return transformSpatialProps(chartProps); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/buildQuery.ts new file mode 100644 index 00000000000..b22ef0b6eea --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/buildQuery.ts @@ -0,0 +1,95 @@ +/** + * 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 { + buildQueryContext, + ensureIsArray, + SqlaFormData, + QueryFormColumn, +} from '@superset-ui/core'; +import { addNullFilters, addTooltipColumnsToQuery } from '../buildQueryUtils'; + +export interface DeckPathFormData extends SqlaFormData { + line_column?: string; + line_type?: 'polyline' | 'json' | 'geohash'; + metric?: string; + reverse_long_lat?: boolean; + js_columns?: string[]; + tooltip_contents?: unknown[]; + tooltip_template?: string; +} + +export default function buildQuery(formData: DeckPathFormData) { + const { line_column, metric, js_columns, tooltip_contents } = formData; + + if (!line_column) { + throw new Error('Line column is required for Path charts'); + } + + return buildQueryContext(formData, { + buildQuery: baseQueryObject => { + const columns = ensureIsArray( + baseQueryObject.columns || [], + ) as QueryFormColumn[]; + const metrics = ensureIsArray(baseQueryObject.metrics || []); + const groupby = ensureIsArray( + baseQueryObject.groupby || [], + ) as QueryFormColumn[]; + const jsColumns = ensureIsArray(js_columns || []); + + if (baseQueryObject.metrics?.length || metric) { + if (metric && !metrics.includes(metric)) { + metrics.push(metric); + } + if (!groupby.includes(line_column)) { + groupby.push(line_column); + } + } else if (!columns.includes(line_column)) { + columns.push(line_column); + } + + jsColumns.forEach(col => { + if (!columns.includes(col) && !groupby.includes(col)) { + columns.push(col); + } + }); + + const finalColumns = addTooltipColumnsToQuery(columns, tooltip_contents); + const finalGroupby = addTooltipColumnsToQuery(groupby, tooltip_contents); + + const filters = addNullFilters( + ensureIsArray(baseQueryObject.filters || []), + [line_column], + ); + + const isTimeseries = Boolean(formData.time_grain_sqla); + + return [ + { + ...baseQueryObject, + columns: finalColumns, + metrics, + groupby: finalGroupby, + filters, + is_timeseries: isTimeseries, + row_limit: baseQueryObject.row_limit, + }, + ]; + }, + }); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/index.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/index.ts index fd930a27808..0c5ea4b4210 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/index.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/index.ts @@ -21,7 +21,8 @@ import thumbnail from './images/thumbnail.png'; import thumbnailDark from './images/thumbnail-dark.png'; import example from './images/example.png'; import exampleDark from './images/example-dark.png'; -import transformProps from '../../transformProps'; +import buildQuery from './buildQuery'; +import transformProps from './transformProps'; import controlPanel from './controlPanel'; const metadata = new ChartMetadata({ @@ -32,7 +33,6 @@ const metadata = new ChartMetadata({ thumbnail, thumbnailDark, exampleGallery: [{ url: example, urlDark: exampleDark }], - useLegacyApi: true, tags: [t('deckGL'), t('Web')], behaviors: [Behavior.InteractiveChart], }); @@ -40,6 +40,7 @@ const metadata = new ChartMetadata({ export default class PathChartPlugin extends ChartPlugin { constructor() { super({ + buildQuery, loadChart: () => import('./Path'), controlPanel, metadata, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/transformProps.ts new file mode 100644 index 00000000000..702ca92b4ae --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Path/transformProps.ts @@ -0,0 +1,166 @@ +/** + * 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 { ChartProps, DTTM_ALIAS } from '@superset-ui/core'; +import { addJsColumnsToExtraProps, DataRecord } from '../spatialUtils'; +import { + createBaseTransformResult, + getRecordsFromQuery, + getMetricLabelFromFormData, + parseMetricValue, + addPropertiesToFeature, +} from '../transformUtils'; +import { DeckPathFormData } from './buildQuery'; + +declare global { + interface Window { + polyline?: { + decode: (data: string) => [number, number][]; + }; + geohash?: { + decode: (data: string) => { longitude: number; latitude: number }; + }; + } +} + +export interface DeckPathTransformPropsFormData extends DeckPathFormData { + js_data_mutator?: string; + js_tooltip?: string; + js_onclick_href?: string; +} + +interface PathFeature { + path: [number, number][]; + metric?: number; + timestamp?: unknown; + extraProps?: Record; + [key: string]: unknown; +} + +const decoders = { + json: (data: string): [number, number][] => { + try { + const parsed = JSON.parse(data); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + return []; + } + }, + polyline: (data: string): [number, number][] => { + try { + if (typeof window !== 'undefined' && window.polyline) { + return window.polyline.decode(data); + } + return []; + } catch (error) { + return []; + } + }, + geohash: (data: string): [number, number][] => { + try { + if (typeof window !== 'undefined' && window.geohash) { + const decoded = window.geohash.decode(data); + return [[decoded.longitude, decoded.latitude]]; + } + return []; + } catch (error) { + return []; + } + }, +}; + +function processPathData( + records: DataRecord[], + lineColumn: string, + lineType: 'polyline' | 'json' | 'geohash' = 'json', + reverseLongLat: boolean = false, + metricLabel?: string, + jsColumns?: string[], +): PathFeature[] { + if (!records.length || !lineColumn) { + return []; + } + + const decoder = decoders[lineType] || decoders.json; + const excludeKeys = new Set( + [ + lineType !== 'geohash' ? lineColumn : undefined, + 'timestamp', + DTTM_ALIAS, + metricLabel, + ...(jsColumns || []), + ].filter(Boolean) as string[], + ); + + return records.map(record => { + const lineData = record[lineColumn]; + let path: [number, number][] = []; + + if (lineData) { + path = decoder(String(lineData)); + if (reverseLongLat && path.length > 0) { + path = path.map(([lng, lat]) => [lat, lng]); + } + } + + let feature: PathFeature = { + path, + timestamp: record[DTTM_ALIAS], + extraProps: {}, + }; + + if (metricLabel && record[metricLabel] != null) { + const metricValue = parseMetricValue(record[metricLabel]); + if (metricValue !== undefined) { + feature.metric = metricValue; + } + } + + feature = addJsColumnsToExtraProps(feature, record, jsColumns); + feature = addPropertiesToFeature(feature, record, excludeKeys); + return feature; + }); +} + +export default function transformProps(chartProps: ChartProps) { + const { rawFormData: formData } = chartProps; + const { + line_column, + line_type = 'json', + metric, + reverse_long_lat = false, + js_columns, + } = formData as DeckPathTransformPropsFormData; + + const metricLabel = getMetricLabelFromFormData(metric); + const records = getRecordsFromQuery(chartProps.queriesData); + const features = processPathData( + records, + line_column || '', + line_type, + reverse_long_lat, + metricLabel, + js_columns, + ).reverse(); + + return createBaseTransformResult( + chartProps, + features, + metricLabel ? [metricLabel] : [], + ); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx index 630a2639d01..69c21e9079b 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx @@ -118,7 +118,7 @@ export const getLayer: GetLayerType = function ({ fd.fill_color_picker; const sc: { r: number; g: number; b: number; a: number } = fd.stroke_color_picker; - const defaultBreakpointColor = fd.deafult_breakpoint_color; + const defaultBreakpointColor = fd.default_breakpoint_color; let data = [...payload.data.features]; if (fd.js_data_mutator) { diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.ts new file mode 100644 index 00000000000..257096525c0 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.ts @@ -0,0 +1,111 @@ +/** + * 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 { + buildQueryContext, + ensureIsArray, + SqlaFormData, + getMetricLabel, + QueryObjectFilterClause, + QueryObject, + QueryFormColumn, +} from '@superset-ui/core'; +import { addTooltipColumnsToQuery } from '../buildQueryUtils'; + +export interface DeckPolygonFormData extends SqlaFormData { + line_column?: string; + line_type?: string; + metric?: string; + point_radius_fixed?: { + value?: string; + }; + reverse_long_lat?: boolean; + filter_nulls?: boolean; + js_columns?: string[]; + tooltip_contents?: unknown[]; + tooltip_template?: string; +} + +export default function buildQuery(formData: DeckPolygonFormData) { + const { + line_column, + metric, + point_radius_fixed, + filter_nulls = true, + js_columns, + tooltip_contents, + } = formData; + + if (!line_column) { + throw new Error('Polygon column is required for Polygon charts'); + } + + return buildQueryContext(formData, (baseQueryObject: QueryObject) => { + let columns: QueryFormColumn[] = [ + ...ensureIsArray(baseQueryObject.columns || []), + line_column, + ]; + + const jsColumns = ensureIsArray(js_columns || []); + jsColumns.forEach((col: string) => { + if (!columns.includes(col)) { + columns.push(col); + } + }); + + columns = addTooltipColumnsToQuery(columns, tooltip_contents); + + const metrics = []; + if (metric) { + metrics.push(metric); + } + if (point_radius_fixed?.value) { + metrics.push(point_radius_fixed.value); + } + + const filters = ensureIsArray(baseQueryObject.filters || []); + if (filter_nulls) { + const nullFilters: QueryObjectFilterClause[] = [ + { + col: line_column, + op: 'IS NOT NULL', + }, + ]; + + if (metric) { + nullFilters.push({ + col: getMetricLabel(metric), + op: 'IS NOT NULL', + }); + } + + filters.push(...nullFilters); + } + + return [ + { + ...baseQueryObject, + columns, + metrics, + filters, + is_timeseries: false, + row_limit: baseQueryObject.row_limit, + }, + ]; + }); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/index.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/index.ts index 2f793a45398..fa89cc4474b 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/index.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/index.ts @@ -21,7 +21,8 @@ import thumbnail from './images/thumbnail.png'; import thumbnailDark from './images/thumbnail-dark.png'; import example from './images/example.png'; import exampleDark from './images/example-dark.png'; -import transformProps from '../../transformProps'; +import transformProps from './transformProps'; +import buildQuery from './buildQuery'; import controlPanel from './controlPanel'; const metadata = new ChartMetadata({ @@ -34,7 +35,6 @@ const metadata = new ChartMetadata({ thumbnail, thumbnailDark, exampleGallery: [{ url: example, urlDark: exampleDark }], - useLegacyApi: true, tags: [t('deckGL'), t('3D'), t('Multi-Dimensions'), t('Geo')], behaviors: [Behavior.InteractiveChart], }); @@ -42,6 +42,7 @@ const metadata = new ChartMetadata({ export default class PolygonChartPlugin extends ChartPlugin { constructor() { super({ + buildQuery, loadChart: () => import('./Polygon'), controlPanel, metadata, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts new file mode 100644 index 00000000000..b1c73b72d51 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts @@ -0,0 +1,143 @@ +/** + * 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 { ChartProps } from '@superset-ui/core'; +import { addJsColumnsToExtraProps, DataRecord } from '../spatialUtils'; +import { + createBaseTransformResult, + getRecordsFromQuery, + getMetricLabelFromFormData, + parseMetricValue, + addPropertiesToFeature, +} from '../transformUtils'; +import { DeckPolygonFormData } from './buildQuery'; + +interface PolygonFeature { + polygon?: number[][]; + name?: string; + elevation?: number; + extraProps?: Record; + metrics?: Record; +} + +function processPolygonData( + records: DataRecord[], + formData: DeckPolygonFormData, +): PolygonFeature[] { + const { + line_column, + line_type, + metric, + point_radius_fixed, + reverse_long_lat, + js_columns, + } = formData; + + if (!line_column || !records.length) { + return []; + } + + const metricLabel = getMetricLabelFromFormData(metric); + const elevationLabel = getMetricLabelFromFormData(point_radius_fixed); + const excludeKeys = new Set([line_column, ...(js_columns || [])]); + + return records + .map(record => { + let feature: PolygonFeature = { + extraProps: {}, + metrics: {}, + }; + + feature = addJsColumnsToExtraProps(feature, record, js_columns); + const updatedFeature = addPropertiesToFeature( + feature as unknown as Record, + record, + excludeKeys, + ); + feature = updatedFeature as unknown as PolygonFeature; + + const rawPolygonData = record[line_column]; + if (!rawPolygonData) { + return null; + } + + try { + let polygonCoords: number[][]; + + switch (line_type) { + case 'json': { + const parsed = + typeof rawPolygonData === 'string' + ? JSON.parse(rawPolygonData) + : rawPolygonData; + + if (parsed.coordinates) { + polygonCoords = parsed.coordinates[0] || parsed.coordinates; + } else if (Array.isArray(parsed)) { + polygonCoords = parsed; + } else { + return null; + } + break; + } + case 'geohash': + case 'zipcode': + default: { + polygonCoords = Array.isArray(rawPolygonData) ? rawPolygonData : []; + break; + } + } + + if (reverse_long_lat && polygonCoords.length > 0) { + polygonCoords = polygonCoords.map(coord => [coord[1], coord[0]]); + } + + feature.polygon = polygonCoords; + + if (elevationLabel && record[elevationLabel] != null) { + const elevationValue = parseMetricValue(record[elevationLabel]); + if (elevationValue !== undefined) { + feature.elevation = elevationValue; + } + } + + if (metricLabel && record[metricLabel] != null) { + const metricValue = record[metricLabel]; + if ( + typeof metricValue === 'string' || + typeof metricValue === 'number' + ) { + feature.metrics![metricLabel] = metricValue; + } + } + } catch { + return null; + } + + return feature; + }) + .filter((feature): feature is PolygonFeature => feature !== null); +} + +export default function transformProps(chartProps: ChartProps) { + const { rawFormData: formData } = chartProps; + const records = getRecordsFromQuery(chartProps.queriesData); + const features = processPolygonData(records, formData as DeckPolygonFormData); + + return createBaseTransformResult(chartProps, features); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.ts new file mode 100644 index 00000000000..66b71461902 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.ts @@ -0,0 +1,105 @@ +/** + * 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 { + buildQueryContext, + ensureIsArray, + QueryFormOrderBy, + SqlaFormData, + QueryFormColumn, + QueryObject, +} from '@superset-ui/core'; +import { + getSpatialColumns, + addSpatialNullFilters, + SpatialFormData, +} from '../spatialUtils'; +import { + addJsColumnsToColumns, + processMetricsArray, + addTooltipColumnsToQuery, +} from '../buildQueryUtils'; + +export interface DeckScatterFormData + extends Omit, + SqlaFormData { + point_radius_fixed?: { + value?: string; + }; + multiplier?: number; + point_unit?: string; + min_radius?: number; + max_radius?: number; + color_picker?: { r: number; g: number; b: number; a: number }; + category_name?: string; +} + +export default function buildQuery(formData: DeckScatterFormData) { + const { + spatial, + point_radius_fixed, + category_name, + js_columns, + tooltip_contents, + } = formData; + + if (!spatial) { + throw new Error('Spatial configuration is required for Scatter charts'); + } + + return buildQueryContext(formData, { + buildQuery: (baseQueryObject: QueryObject) => { + const spatialColumns = getSpatialColumns(spatial); + let columns = [...(baseQueryObject.columns || []), ...spatialColumns]; + + if (category_name) { + columns.push(category_name); + } + + const columnStrings = columns.map(col => + typeof col === 'string' ? col : col.label || col.sqlExpression || '', + ); + const withJsColumns = addJsColumnsToColumns(columnStrings, js_columns); + + columns = withJsColumns as QueryFormColumn[]; + columns = addTooltipColumnsToQuery(columns, tooltip_contents); + + const metrics = processMetricsArray([point_radius_fixed?.value]); + const filters = addSpatialNullFilters( + spatial, + ensureIsArray(baseQueryObject.filters || []), + ); + + const orderby = point_radius_fixed?.value + ? ([[point_radius_fixed.value, false]] as QueryFormOrderBy[]) + : (baseQueryObject.orderby as QueryFormOrderBy[]) || []; + + return [ + { + ...baseQueryObject, + columns, + metrics, + filters, + orderby, + is_timeseries: false, + row_limit: baseQueryObject.row_limit, + }, + ]; + }, + }); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/index.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/index.ts index 4f32f4a1c77..cef908989a3 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/index.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/index.ts @@ -21,7 +21,8 @@ import thumbnail from './images/thumbnail.png'; import thumbnailDark from './images/thumbnail-dark.png'; import example from './images/example.png'; import exampleDark from './images/example-dark.png'; -import transformProps from '../../transformProps'; +import buildQuery from './buildQuery'; +import transformProps from './transformProps'; import controlPanel from './controlPanel'; const metadata = new ChartMetadata({ @@ -34,7 +35,6 @@ const metadata = new ChartMetadata({ thumbnail, thumbnailDark, exampleGallery: [{ url: example, urlDark: exampleDark }], - useLegacyApi: true, tags: [ t('deckGL'), t('Comparison'), @@ -50,6 +50,7 @@ const metadata = new ChartMetadata({ export default class ScatterChartPlugin extends ChartPlugin { constructor() { super({ + buildQuery, loadChart: () => import('./Scatter'), controlPanel, metadata, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.ts new file mode 100644 index 00000000000..baadec33c91 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.ts @@ -0,0 +1,116 @@ +/** + * 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 { ChartProps } from '@superset-ui/core'; +import { processSpatialData, DataRecord } from '../spatialUtils'; +import { + createBaseTransformResult, + getRecordsFromQuery, + getMetricLabelFromFormData, + parseMetricValue, + addPropertiesToFeature, +} from '../transformUtils'; +import { DeckScatterFormData } from './buildQuery'; + +interface ScatterPoint { + position: [number, number]; + radius?: number; + color?: [number, number, number, number]; + cat_color?: string; + metric?: number; + extraProps?: Record; + [key: string]: unknown; +} + +function processScatterData( + records: DataRecord[], + spatial: DeckScatterFormData['spatial'], + radiusMetricLabel?: string, + categoryColumn?: string, + jsColumns?: string[], +): ScatterPoint[] { + if (!spatial || !records.length) { + return []; + } + + const spatialFeatures = processSpatialData(records, spatial); + const excludeKeys = new Set([ + 'position', + 'weight', + 'extraProps', + ...(spatial + ? [ + spatial.lonCol, + spatial.latCol, + spatial.lonlatCol, + spatial.geohashCol, + ].filter(Boolean) + : []), + radiusMetricLabel, + categoryColumn, + ...(jsColumns || []), + ]); + + return spatialFeatures.map(feature => { + let scatterPoint: ScatterPoint = { + position: feature.position, + extraProps: feature.extraProps || {}, + }; + + if (radiusMetricLabel && feature[radiusMetricLabel] != null) { + const radiusValue = parseMetricValue(feature[radiusMetricLabel]); + if (radiusValue !== undefined) { + scatterPoint.radius = radiusValue; + scatterPoint.metric = radiusValue; + } + } + + if (categoryColumn && feature[categoryColumn] != null) { + scatterPoint.cat_color = String(feature[categoryColumn]); + } + + scatterPoint = addPropertiesToFeature( + scatterPoint, + feature as DataRecord, + excludeKeys, + ); + return scatterPoint; + }); +} + +export default function transformProps(chartProps: ChartProps) { + const { rawFormData: formData } = chartProps; + const { spatial, point_radius_fixed, category_name, js_columns } = + formData as DeckScatterFormData; + + const radiusMetricLabel = getMetricLabelFromFormData(point_radius_fixed); + const records = getRecordsFromQuery(chartProps.queriesData); + const features = processScatterData( + records, + spatial, + radiusMetricLabel, + category_name, + js_columns, + ); + + return createBaseTransformResult( + chartProps, + features, + radiusMetricLabel ? [radiusMetricLabel] : [], + ); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx index 9ea46cd453e..bbade670445 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx @@ -123,7 +123,7 @@ export const getLayer: GetLayerType = function ({ const colorSchemeType = fd.color_scheme_type as ColorSchemeType & 'default'; const colorRange = getColorRange({ - defaultBreakpointsColor: fd.deafult_breakpoint_color, + defaultBreakpointsColor: fd.default_breakpoint_color, colorBreakpoints: fd.color_breakpoints, fixedColor: fd.color_picker, colorSchemeType, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/buildQuery.ts new file mode 100644 index 00000000000..94607704acf --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/buildQuery.ts @@ -0,0 +1,23 @@ +/** + * 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 { SpatialFormData, buildSpatialQuery } from '../spatialUtils'; + +export default function buildQuery(formData: SpatialFormData) { + return buildSpatialQuery(formData); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/index.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/index.ts index 574d50dffff..87758e37cf7 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/index.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/index.ts @@ -21,7 +21,8 @@ import thumbnail from './images/thumbnail.png'; import thumbnailDark from './images/thumbnail-dark.png'; import example from './images/example.png'; import exampleDark from './images/example-dark.png'; -import transformProps from '../../transformProps'; +import buildQuery from './buildQuery'; +import transformProps from './transformProps'; import controlPanel from './controlPanel'; const metadata = new ChartMetadata({ @@ -34,7 +35,6 @@ const metadata = new ChartMetadata({ thumbnail, thumbnailDark, exampleGallery: [{ url: example, urlDark: exampleDark }], - useLegacyApi: true, tags: [t('deckGL'), t('Comparison'), t('Intensity'), t('Density')], behaviors: [Behavior.InteractiveChart], }); @@ -42,6 +42,7 @@ const metadata = new ChartMetadata({ export default class ScreengridChartPlugin extends ChartPlugin { constructor() { super({ + buildQuery, loadChart: () => import('./Screengrid'), controlPanel, metadata, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/transformProps.ts new file mode 100644 index 00000000000..4b8f437d7fc --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/transformProps.ts @@ -0,0 +1,24 @@ +/** + * 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 { ChartProps } from '@superset-ui/core'; +import { transformSpatialProps } from '../spatialUtils'; + +export default function transformProps(chartProps: ChartProps) { + return transformSpatialProps(chartProps); +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/buildQueryUtils.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/buildQueryUtils.ts new file mode 100644 index 00000000000..f61a71ede7c --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/buildQueryUtils.ts @@ -0,0 +1,142 @@ +/** + * 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 { + getMetricLabel, + QueryObjectFilterClause, + QueryFormColumn, + getColumnLabel, +} from '@superset-ui/core'; + +export function addJsColumnsToColumns( + columns: string[], + jsColumns?: string[], + existingColumns?: string[], +): string[] { + if (!jsColumns?.length) return columns; + + const allExisting = new Set([...columns, ...(existingColumns || [])]); + const result = [...columns]; + + jsColumns.forEach(col => { + if (!allExisting.has(col)) { + result.push(col); + allExisting.add(col); + } + }); + + return result; +} + +export function addNullFilters( + filters: QueryObjectFilterClause[], + columnNames: string[], +): QueryObjectFilterClause[] { + const existingFilters = new Set( + filters + .filter(filter => filter.op === 'IS NOT NULL') + .map(filter => filter.col), + ); + + const nullFilters: QueryObjectFilterClause[] = columnNames + .filter(col => !existingFilters.has(col)) + .map(col => ({ + col, + op: 'IS NOT NULL' as const, + })); + + return [...filters, ...nullFilters]; +} + +export function addMetricNullFilter( + filters: QueryObjectFilterClause[], + metric?: string, +): QueryObjectFilterClause[] { + if (!metric) return filters; + return addNullFilters(filters, [getMetricLabel(metric)]); +} + +export function ensureColumnsUnique(columns: string[]): string[] { + return [...new Set(columns)]; +} + +export function addColumnsIfNotExists( + baseColumns: string[], + newColumns: string[], +): string[] { + const existing = new Set(baseColumns); + const result = [...baseColumns]; + + newColumns.forEach(col => { + if (!existing.has(col)) { + result.push(col); + existing.add(col); + } + }); + + return result; +} + +export function processMetricsArray(metrics: (string | undefined)[]): string[] { + return metrics.filter((metric): metric is string => Boolean(metric)); +} + +export function extractTooltipColumns(tooltipContents?: unknown[]): string[] { + if (!Array.isArray(tooltipContents) || !tooltipContents.length) { + return []; + } + + const columns: string[] = []; + + tooltipContents.forEach(item => { + if (typeof item === 'string') { + columns.push(item); + } else if (item && typeof item === 'object') { + const objItem = item as Record; + if ( + objItem.item_type === 'column' && + typeof objItem.column_name === 'string' + ) { + columns.push(objItem.column_name); + } + } + }); + + return columns; +} + +export function addTooltipColumnsToQuery( + baseColumns: QueryFormColumn[], + tooltipContents?: unknown[], +): QueryFormColumn[] { + const tooltipColumns = extractTooltipColumns(tooltipContents); + + const baseColumnLabels = baseColumns.map(getColumnLabel); + const existingLabels = new Set(baseColumnLabels); + + const result: QueryFormColumn[] = [...baseColumns]; + + tooltipColumns.forEach(col => { + if (!existingLabels.has(col)) { + result.push(col); + existingLabels.add(col); + } + }); + + return result; +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/spatialUtils.test.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/spatialUtils.test.ts new file mode 100644 index 00000000000..c169ed4c33f --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/spatialUtils.test.ts @@ -0,0 +1,604 @@ +/** + * 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 { + ChartProps, + DatasourceType, + QueryObjectFilterClause, + SupersetTheme, +} from '@superset-ui/core'; +import { decode } from 'ngeohash'; + +import { + getSpatialColumns, + addSpatialNullFilters, + buildSpatialQuery, + processSpatialData, + transformSpatialProps, + SpatialFormData, +} from './spatialUtils'; + +jest.mock('ngeohash', () => ({ + decode: jest.fn(), +})); + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + buildQueryContext: jest.fn(), + getMetricLabel: jest.fn(), + ensureIsArray: jest.fn(arr => arr || []), + normalizeOrderBy: jest.fn(({ orderby }) => ({ orderby })), +})); + +// Mock DOM element for bootstrap data +const mockBootstrapData = { + common: { + conf: { + MAPBOX_API_KEY: 'test_api_key', + }, + }, +}; + +Object.defineProperty(document, 'getElementById', { + value: jest.fn().mockReturnValue({ + getAttribute: jest.fn().mockReturnValue(JSON.stringify(mockBootstrapData)), + }), + writable: true, +}); + +const mockDecode = decode as jest.MockedFunction; + +describe('spatialUtils', () => { + test('getSpatialColumns returns correct columns for latlong type', () => { + const spatial: SpatialFormData['spatial'] = { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }; + + const result = getSpatialColumns(spatial); + expect(result).toEqual(['longitude', 'latitude']); + }); + + test('getSpatialColumns returns correct columns for delimited type', () => { + const spatial: SpatialFormData['spatial'] = { + type: 'delimited', + lonlatCol: 'coordinates', + }; + + const result = getSpatialColumns(spatial); + expect(result).toEqual(['coordinates']); + }); + + test('getSpatialColumns returns correct columns for geohash type', () => { + const spatial: SpatialFormData['spatial'] = { + type: 'geohash', + geohashCol: 'geohash_code', + }; + + const result = getSpatialColumns(spatial); + expect(result).toEqual(['geohash_code']); + }); + + test('getSpatialColumns throws error when spatial is null', () => { + expect(() => getSpatialColumns(null as any)).toThrow('Bad spatial key'); + }); + + test('getSpatialColumns throws error when spatial type is missing', () => { + const spatial = {} as SpatialFormData['spatial']; + expect(() => getSpatialColumns(spatial)).toThrow('Bad spatial key'); + }); + + test('getSpatialColumns throws error when latlong columns are missing', () => { + const spatial: SpatialFormData['spatial'] = { + type: 'latlong', + }; + expect(() => getSpatialColumns(spatial)).toThrow( + 'Longitude and latitude columns are required for latlong type', + ); + }); + + test('getSpatialColumns throws error when delimited column is missing', () => { + const spatial: SpatialFormData['spatial'] = { + type: 'delimited', + }; + expect(() => getSpatialColumns(spatial)).toThrow( + 'Longitude/latitude column is required for delimited type', + ); + }); + + test('getSpatialColumns throws error when geohash column is missing', () => { + const spatial: SpatialFormData['spatial'] = { + type: 'geohash', + }; + expect(() => getSpatialColumns(spatial)).toThrow( + 'Geohash column is required for geohash type', + ); + }); + + test('getSpatialColumns throws error for unknown spatial type', () => { + const spatial = { + type: 'unknown', + } as any; + expect(() => getSpatialColumns(spatial)).toThrow( + 'Unknown spatial type: unknown', + ); + }); + + test('addSpatialNullFilters adds null filters for spatial columns', () => { + const spatial: SpatialFormData['spatial'] = { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }; + const existingFilters: QueryObjectFilterClause[] = [ + { col: 'other_col', op: '==', val: 'test' }, + ]; + + const result = addSpatialNullFilters(spatial, existingFilters); + + expect(result).toEqual([ + { col: 'other_col', op: '==', val: 'test' }, + { col: 'longitude', op: 'IS NOT NULL', val: null }, + { col: 'latitude', op: 'IS NOT NULL', val: null }, + ]); + }); + + test('addSpatialNullFilters returns original filters when spatial is null', () => { + const existingFilters: QueryObjectFilterClause[] = [ + { col: 'test_col', op: '==', val: 'test' }, + ]; + + const result = addSpatialNullFilters(null as any, existingFilters); + expect(result).toBe(existingFilters); + }); + + test('addSpatialNullFilters works with empty filters array', () => { + const spatial: SpatialFormData['spatial'] = { + type: 'delimited', + lonlatCol: 'coordinates', + }; + + const result = addSpatialNullFilters(spatial, []); + + expect(result).toEqual([ + { col: 'coordinates', op: 'IS NOT NULL', val: null }, + ]); + }); + + test('buildSpatialQuery throws error when spatial is missing', () => { + const formData = {} as SpatialFormData; + + expect(() => buildSpatialQuery(formData)).toThrow( + 'Spatial configuration is required for this chart', + ); + }); + + test('buildSpatialQuery calls buildQueryContext with correct parameters', () => { + const mockBuildQueryContext = + jest.requireMock('@superset-ui/core').buildQueryContext; + const formData: SpatialFormData = { + spatial: { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }, + size: 'count', + js_columns: ['extra_col'], + } as SpatialFormData; + + buildSpatialQuery(formData); + + expect(mockBuildQueryContext).toHaveBeenCalledWith(formData, { + buildQuery: expect.any(Function), + }); + }); + + test('processSpatialData processes latlong data correctly', () => { + const records = [ + { longitude: -122.4, latitude: 37.8, count: 10, extra: 'test1' }, + { longitude: -122.5, latitude: 37.9, count: 20, extra: 'test2' }, + ]; + const spatial: SpatialFormData['spatial'] = { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }; + const metricLabel = 'count'; + const jsColumns = ['extra']; + + const result = processSpatialData(records, spatial, metricLabel, jsColumns); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + position: [-122.4, 37.8], + weight: 10, + extraProps: { extra: 'test1' }, + }); + expect(result[1]).toEqual({ + position: [-122.5, 37.9], + weight: 20, + extraProps: { extra: 'test2' }, + }); + }); + + test('processSpatialData processes delimited data correctly', () => { + const records = [ + { coordinates: '-122.4,37.8', count: 15 }, + { coordinates: '-122.5,37.9', count: 25 }, + ]; + const spatial: SpatialFormData['spatial'] = { + type: 'delimited', + lonlatCol: 'coordinates', + }; + + const result = processSpatialData(records, spatial, 'count'); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + position: [-122.4, 37.8], + weight: 15, + extraProps: {}, + }); + }); + + test('processSpatialData processes geohash data correctly', () => { + mockDecode.mockReturnValue({ + latitude: 37.8, + longitude: -122.4, + error: { + latitude: 0, + longitude: 0, + }, + }); + + const records = [{ geohash: 'dr5regw3p', count: 30 }]; + const spatial: SpatialFormData['spatial'] = { + type: 'geohash', + geohashCol: 'geohash', + }; + + const result = processSpatialData(records, spatial, 'count'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + position: [-122.4, 37.8], + weight: 30, + extraProps: {}, + }); + expect(mockDecode).toHaveBeenCalledWith('dr5regw3p'); + }); + + test('processSpatialData reverses coordinates when reverseCheckbox is true', () => { + const records = [{ longitude: -122.4, latitude: 37.8, count: 10 }]; + const spatial: SpatialFormData['spatial'] = { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + reverseCheckbox: true, + }; + + const result = processSpatialData(records, spatial, 'count'); + + expect(result[0].position).toEqual([37.8, -122.4]); + }); + + test('processSpatialData handles invalid coordinates', () => { + const records = [ + { longitude: 'invalid', latitude: 37.8, count: 10 }, + { longitude: -122.4, latitude: NaN, count: 20 }, + // 'latlong' spatial type expects longitude/latitude fields + // so records with 'coordinates' should be filtered out + { coordinates: 'invalid,coords', count: 30 }, + { coordinates: '-122.4,invalid', count: 40 }, + ]; + const spatial: SpatialFormData['spatial'] = { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }; + + const result = processSpatialData(records, spatial, 'count'); + + expect(result).toHaveLength(0); + }); + + test('processSpatialData handles missing metric values', () => { + const records = [ + { longitude: -122.4, latitude: 37.8, count: null }, + { longitude: -122.5, latitude: 37.9 }, + { longitude: -122.6, latitude: 38.0, count: 'invalid' }, + ]; + const spatial: SpatialFormData['spatial'] = { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }; + + const result = processSpatialData(records, spatial, 'count'); + + expect(result).toHaveLength(3); + expect(result[0].weight).toBe(1); + expect(result[1].weight).toBe(1); + expect(result[2].weight).toBe(1); + }); + + test('processSpatialData returns empty array for empty records', () => { + const spatial: SpatialFormData['spatial'] = { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }; + + const result = processSpatialData([], spatial); + + expect(result).toEqual([]); + }); + + test('processSpatialData returns empty array when spatial is null', () => { + const records = [{ longitude: -122.4, latitude: 37.8 }]; + + const result = processSpatialData(records, null as any); + + expect(result).toEqual([]); + }); + + test('processSpatialData handles delimited coordinate edge cases', () => { + const records = [ + { coordinates: '', count: 10 }, + { coordinates: null, count: 20 }, + { coordinates: undefined, count: 30 }, + { coordinates: '-122.4', count: 40 }, // only one coordinate + { coordinates: 'a,b', count: 50 }, // non-numeric + { coordinates: ' -122.4 , 37.8 ', count: 60 }, // with spaces + ]; + const spatial: SpatialFormData['spatial'] = { + type: 'delimited', + lonlatCol: 'coordinates', + }; + + const result = processSpatialData(records, spatial, 'count'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + position: [-122.4, 37.8], + weight: 60, + extraProps: {}, + }); + }); + + test('processSpatialData copies additional properties correctly', () => { + const records = [ + { + longitude: -122.4, + latitude: 37.8, + count: 10, + category: 'A', + description: 'Test location', + extra_col: 'extra_value', + }, + ]; + const spatial: SpatialFormData['spatial'] = { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }; + const jsColumns = ['extra_col']; + + const result = processSpatialData(records, spatial, 'count', jsColumns); + + expect(result[0]).toEqual({ + position: [-122.4, 37.8], + weight: 10, + extraProps: { extra_col: 'extra_value' }, + category: 'A', + description: 'Test location', + }); + + expect(result[0]).not.toHaveProperty('longitude'); + expect(result[0]).not.toHaveProperty('latitude'); + expect(result[0]).not.toHaveProperty('count'); + expect(result[0]).not.toHaveProperty('extra_col'); + }); + + test('transformSpatialProps transforms chart props correctly', () => { + const mockGetMetricLabel = + jest.requireMock('@superset-ui/core').getMetricLabel; + mockGetMetricLabel.mockReturnValue('count_label'); + + const chartProps: ChartProps = { + datasource: { + id: 1, + type: DatasourceType.Table, + columns: [], + name: '', + metrics: [], + }, + height: 400, + width: 600, + hooks: { + onAddFilter: jest.fn(), + onContextMenu: jest.fn(), + setControlValue: jest.fn(), + setDataMask: jest.fn(), + }, + queriesData: [ + { + data: [ + { longitude: -122.4, latitude: 37.8, count: 10 }, + { longitude: -122.5, latitude: 37.9, count: 20 }, + ], + }, + ], + rawFormData: { + spatial: { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }, + size: 'count', + js_columns: [], + viewport: { + zoom: 10, + latitude: 37.8, + longitude: -122.4, + }, + } as unknown as SpatialFormData, + filterState: {}, + emitCrossFilters: true, + annotationData: {}, + rawDatasource: {}, + initialValues: {}, + formData: { + spatial: { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }, + size: 'count', + js_columns: [], + viewport: { + zoom: 10, + latitude: 37.8, + longitude: -122.4, + }, + }, + ownState: {}, + behaviors: [], + theme: {} as unknown as SupersetTheme, + }; + + const result = transformSpatialProps(chartProps); + + expect(result).toMatchObject({ + datasource: chartProps.datasource, + emitCrossFilters: chartProps.emitCrossFilters, + formData: chartProps.rawFormData, + height: 400, + width: 600, + filterState: {}, + onAddFilter: chartProps.hooks.onAddFilter, + onContextMenu: chartProps.hooks.onContextMenu, + setControlValue: chartProps.hooks.setControlValue, + setDataMask: chartProps.hooks.setDataMask, + viewport: { + zoom: 10, + latitude: 37.8, + longitude: -122.4, + height: 400, + width: 600, + }, + }); + + expect(result.payload.data.features).toHaveLength(2); + expect(result.payload.data.mapboxApiKey).toBe('test_api_key'); + expect(result.payload.data.metricLabels).toEqual(['count_label']); + }); + + test('transformSpatialProps handles missing hooks gracefully', () => { + const chartProps: ChartProps = { + datasource: { + id: 1, + type: DatasourceType.Table, + columns: [], + name: '', + metrics: [], + }, + height: 400, + width: 600, + hooks: {}, + queriesData: [{ data: [] }], + rawFormData: { + spatial: { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }, + } as SpatialFormData, + filterState: {}, + emitCrossFilters: true, + annotationData: {}, + rawDatasource: {}, + initialValues: {}, + formData: { + spatial: { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }, + }, + ownState: {}, + behaviors: [], + theme: {} as unknown as SupersetTheme, + }; + + const result = transformSpatialProps(chartProps); + + expect(typeof result.onAddFilter).toBe('function'); + expect(typeof result.onContextMenu).toBe('function'); + expect(typeof result.setControlValue).toBe('function'); + expect(typeof result.setDataMask).toBe('function'); + expect(typeof result.setTooltip).toBe('function'); + }); + + test('transformSpatialProps handles missing metric', () => { + const mockGetMetricLabel = + jest.requireMock('@superset-ui/core').getMetricLabel; + mockGetMetricLabel.mockReturnValue(undefined); + + const chartProps: ChartProps = { + datasource: { + id: 1, + type: DatasourceType.Table, + columns: [], + name: '', + metrics: [], + }, + height: 400, + width: 600, + hooks: {}, + queriesData: [{ data: [] }], + rawFormData: { + spatial: { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }, + } as SpatialFormData, + filterState: {}, + emitCrossFilters: true, + annotationData: {}, + rawDatasource: {}, + initialValues: {}, + formData: { + spatial: { + type: 'latlong', + lonCol: 'longitude', + latCol: 'latitude', + }, + }, + ownState: {}, + behaviors: [], + theme: {} as unknown as SupersetTheme, + }; + + const result = transformSpatialProps(chartProps); + + expect(result.payload.data.metricLabels).toEqual([]); + }); +}); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/spatialUtils.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/spatialUtils.ts new file mode 100644 index 00000000000..28625c5872b --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/spatialUtils.ts @@ -0,0 +1,400 @@ +/** + * 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 { + buildQueryContext, + getMetricLabel, + QueryFormData, + QueryObjectFilterClause, + ensureIsArray, + ChartProps, + normalizeOrderBy, +} from '@superset-ui/core'; +import { decode } from 'ngeohash'; +import { addTooltipColumnsToQuery } from './buildQueryUtils'; + +export interface SpatialConfiguration { + type: 'latlong' | 'delimited' | 'geohash'; + lonCol?: string; + latCol?: string; + lonlatCol?: string; + geohashCol?: string; + reverseCheckbox?: boolean; +} + +export interface DataRecord { + [key: string]: string | number | null | undefined; +} + +export interface BootstrapData { + common?: { + conf?: { + MAPBOX_API_KEY?: string; + }; + }; +} + +export interface SpatialFormData extends QueryFormData { + spatial: SpatialConfiguration; + size?: string; + grid_size?: number; + js_data_mutator?: string; + js_agg_function?: string; + js_columns?: string[]; + color_scheme?: string; + color_scheme_type?: string; + color_breakpoints?: number[]; + default_breakpoint_color?: string; + tooltip_contents?: unknown[]; + tooltip_template?: string; + color_picker?: string; +} + +export interface SpatialPoint { + position: [number, number]; + weight: number; + extraProps?: Record; + [key: string]: unknown; +} + +export function getSpatialColumns(spatial: SpatialConfiguration): string[] { + if (!spatial || !spatial.type) { + throw new Error('Bad spatial key'); + } + + switch (spatial.type) { + case 'latlong': + if (!spatial.lonCol || !spatial.latCol) { + throw new Error( + 'Longitude and latitude columns are required for latlong type', + ); + } + return [spatial.lonCol, spatial.latCol]; + case 'delimited': + if (!spatial.lonlatCol) { + throw new Error( + 'Longitude/latitude column is required for delimited type', + ); + } + return [spatial.lonlatCol]; + case 'geohash': + if (!spatial.geohashCol) { + throw new Error('Geohash column is required for geohash type'); + } + return [spatial.geohashCol]; + default: + throw new Error(`Unknown spatial type: ${spatial.type}`); + } +} + +export function addSpatialNullFilters( + spatial: SpatialConfiguration, + filters: QueryObjectFilterClause[], +): QueryObjectFilterClause[] { + if (!spatial) return filters; + + const spatialColumns = getSpatialColumns(spatial); + const nullFilters: QueryObjectFilterClause[] = spatialColumns.map(column => ({ + col: column, + op: 'IS NOT NULL', + val: null, + })); + + return [...filters, ...nullFilters]; +} + +export function buildSpatialQuery(formData: SpatialFormData) { + const { spatial, size: metric, js_columns, tooltip_contents } = formData; + + if (!spatial) { + throw new Error(`Spatial configuration is required for this chart`); + } + return buildQueryContext(formData, { + buildQuery: baseQueryObject => { + const spatialColumns = getSpatialColumns(spatial); + let columns = [...(baseQueryObject.columns || []), ...spatialColumns]; + const metrics = metric ? [metric] : []; + + if (js_columns?.length) { + js_columns.forEach(col => { + if (!columns.includes(col)) { + columns.push(col); + } + }); + } + + columns = addTooltipColumnsToQuery(columns, tooltip_contents); + + const filters = addSpatialNullFilters( + spatial, + ensureIsArray(baseQueryObject.filters || []), + ); + + const orderby = metric + ? normalizeOrderBy({ orderby: [[metric, false]] }).orderby + : baseQueryObject.orderby; + + return [ + { + ...baseQueryObject, + columns, + metrics, + filters, + orderby, + is_timeseries: false, + row_limit: baseQueryObject.row_limit, + }, + ]; + }, + }); +} + +function parseCoordinates(latlong: string): [number, number] | null { + if (!latlong || typeof latlong !== 'string') { + return null; + } + + try { + const coords = latlong.split(',').map(coord => parseFloat(coord.trim())); + if ( + coords.length === 2 && + !Number.isNaN(coords[0]) && + !Number.isNaN(coords[1]) + ) { + return [coords[0], coords[1]]; + } + return null; + } catch (error) { + return null; + } +} + +function reverseGeohashDecode(geohashCode: string): [number, number] | null { + if (!geohashCode || typeof geohashCode !== 'string') { + return null; + } + + try { + const { latitude: lat, longitude: lng } = decode(geohashCode); + if ( + Number.isNaN(lat) || + Number.isNaN(lng) || + lat < -90 || + lat > 90 || + lng < -180 || + lng > 180 + ) { + return null; + } + return [lng, lat]; + } catch (error) { + return null; + } +} + +export function addJsColumnsToExtraProps< + T extends { extraProps?: Record }, +>(feature: T, record: DataRecord, jsColumns?: string[]): T { + if (!jsColumns?.length) { + return feature; + } + + const extraProps: Record = { ...(feature.extraProps ?? {}) }; + + jsColumns.forEach(col => { + if (record[col] !== undefined) { + extraProps[col] = record[col]; + } + }); + + return { ...feature, extraProps }; +} + +export function processSpatialData( + records: DataRecord[], + spatial: SpatialConfiguration, + metricLabel?: string, + jsColumns?: string[], +): SpatialPoint[] { + if (!spatial || !records.length) { + return []; + } + + const features: SpatialPoint[] = []; + const spatialColumns = getSpatialColumns(spatial); + const jsColumnsSet = jsColumns ? new Set(jsColumns) : null; + const spatialColumnsSet = new Set(spatialColumns); + + for (const record of records) { + let position: [number, number] | null = null; + + switch (spatial.type) { + case 'latlong': + if (spatial.lonCol && spatial.latCol) { + const lon = parseFloat(String(record[spatial.lonCol] ?? '')); + const lat = parseFloat(String(record[spatial.latCol] ?? '')); + if (!Number.isNaN(lon) && !Number.isNaN(lat)) { + position = [lon, lat]; + } + } + break; + case 'delimited': + if (spatial.lonlatCol) { + position = parseCoordinates(String(record[spatial.lonlatCol] ?? '')); + } + break; + case 'geohash': + if (spatial.geohashCol) { + const geohashValue = record[spatial.geohashCol]; + if (geohashValue) { + position = reverseGeohashDecode(String(geohashValue)); + } + } + break; + default: + continue; + } + + if (!position) { + continue; + } + + if (spatial.reverseCheckbox) { + position = [position[1], position[0]]; + } + + let weight = 1; + if (metricLabel && record[metricLabel] != null) { + const metricValue = parseFloat(String(record[metricLabel])); + if (!Number.isNaN(metricValue)) { + weight = metricValue; + } + } + + let spatialPoint: SpatialPoint = { + position, + weight, + extraProps: {}, + }; + + spatialPoint = addJsColumnsToExtraProps(spatialPoint, record, jsColumns); + Object.keys(record).forEach(key => { + if (spatialColumnsSet.has(key)) { + return; + } + + if (key === metricLabel) { + return; + } + + if (jsColumnsSet?.has(key)) { + return; + } + + spatialPoint[key] = record[key]; + }); + + features.push(spatialPoint); + } + + return features; +} + +const NOOP = () => {}; + +export function getMapboxApiKey(mapboxApiKey?: string): string { + if (mapboxApiKey) { + return mapboxApiKey; + } + + if (typeof document !== 'undefined') { + try { + const appContainer = document.getElementById('app'); + const dataBootstrap = appContainer?.getAttribute('data-bootstrap'); + if (dataBootstrap) { + const bootstrapData: BootstrapData = JSON.parse(dataBootstrap); + return bootstrapData?.common?.conf?.MAPBOX_API_KEY || ''; + } + } catch (error) { + throw new Error( + `Failed to read MAPBOX_API_KEY from bootstrap data: ${error}`, + ); + } + } + + return ''; +} + +export function transformSpatialProps(chartProps: ChartProps) { + const { + datasource, + height, + hooks, + queriesData, + rawFormData: formData, + width, + filterState, + emitCrossFilters, + } = chartProps; + + const { + onAddFilter = NOOP, + onContextMenu = NOOP, + setControlValue = NOOP, + setDataMask = NOOP, + } = hooks; + + const { spatial, size: metric, js_columns } = formData as SpatialFormData; + const metricLabel = metric ? getMetricLabel(metric) : undefined; + + const queryData = queriesData[0]; + const records = queryData?.data || []; + const features = processSpatialData( + records, + spatial, + metricLabel, + js_columns, + ); + + return { + datasource, + emitCrossFilters, + formData, + height, + onAddFilter, + onContextMenu, + payload: { + ...queryData, + data: { + features, + mapboxApiKey: getMapboxApiKey(), + metricLabels: metricLabel ? [metricLabel] : [], + }, + }, + setControlValue, + filterState, + viewport: { + ...formData.viewport, + height, + width, + }, + width, + setDataMask, + setTooltip: () => {}, + }; +} diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.ts new file mode 100644 index 00000000000..6427db900a7 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.ts @@ -0,0 +1,142 @@ +/** + * 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 { ChartProps, getMetricLabel } from '@superset-ui/core'; +import { getMapboxApiKey, DataRecord } from './spatialUtils'; + +const NOOP = () => {}; + +export interface BaseHooks { + onAddFilter: ChartProps['hooks']['onAddFilter']; + onContextMenu: ChartProps['hooks']['onContextMenu']; + setControlValue: ChartProps['hooks']['setControlValue']; + setDataMask: ChartProps['hooks']['setDataMask']; +} + +export interface BaseTransformPropsResult { + datasource: ChartProps['datasource']; + emitCrossFilters: ChartProps['emitCrossFilters']; + formData: ChartProps['rawFormData']; + height: ChartProps['height']; + onAddFilter: ChartProps['hooks']['onAddFilter']; + onContextMenu: ChartProps['hooks']['onContextMenu']; + payload: { + data: { + features: unknown[]; + mapboxApiKey: string; + metricLabels?: string[]; + }; + [key: string]: unknown; + }; + setControlValue: ChartProps['hooks']['setControlValue']; + filterState: ChartProps['filterState']; + viewport: { + height: number; + width: number; + [key: string]: unknown; + }; + width: ChartProps['width']; + setDataMask: ChartProps['hooks']['setDataMask']; + setTooltip: () => void; +} + +export function extractHooks(hooks: ChartProps['hooks']): BaseHooks { + return { + onAddFilter: hooks?.onAddFilter || NOOP, + onContextMenu: hooks?.onContextMenu || NOOP, + setControlValue: hooks?.setControlValue || NOOP, + setDataMask: hooks?.setDataMask || NOOP, + }; +} + +export function createBaseTransformResult( + chartProps: ChartProps, + features: unknown[], + metricLabels?: string[], +): BaseTransformPropsResult { + const { + datasource, + height, + queriesData, + rawFormData: formData, + width, + filterState, + emitCrossFilters, + } = chartProps; + + const hooks = extractHooks(chartProps.hooks); + const queryData = queriesData[0]; + + return { + datasource, + emitCrossFilters, + formData, + height, + ...hooks, + payload: { + ...queryData, + data: { + features, + mapboxApiKey: getMapboxApiKey(), + metricLabels: metricLabels || [], + }, + }, + filterState, + viewport: { + ...formData.viewport, + height, + width, + }, + width, + setTooltip: NOOP, + }; +} + +export function getRecordsFromQuery( + queriesData: ChartProps['queriesData'], +): DataRecord[] { + return queriesData[0]?.data || []; +} + +export function parseMetricValue(value: unknown): number | undefined { + if (value == null) return undefined; + const parsed = parseFloat(String(value)); + return Number.isNaN(parsed) ? undefined : parsed; +} + +export function addPropertiesToFeature>( + feature: T, + record: DataRecord, + excludeKeys: Set, +): T { + const result = { ...feature } as Record; + Object.keys(record).forEach(key => { + if (!excludeKeys.has(key)) { + result[key] = record[key]; + } + }); + return result as T; +} + +export function getMetricLabelFromFormData( + metric: string | { value?: string } | undefined, +): string | undefined { + if (!metric) return undefined; + if (typeof metric === 'string') return getMetricLabel(metric); + return metric.value ? getMetricLabel(metric.value) : undefined; +} 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 be9359a6162..be06ecaa15b 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 @@ -615,7 +615,7 @@ export const deckGLColorBreakpointsSelect: CustomControlItem = { }; export const breakpointsDefaultColor: CustomControlItem = { - name: 'deafult_breakpoint_color', + name: 'default_breakpoint_color', config: { label: t('Default color'), type: 'ColorPickerControl', diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils/crossFiltersDataMask.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils/crossFiltersDataMask.ts index 0b63a1017fa..95a8350d11a 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils/crossFiltersDataMask.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utils/crossFiltersDataMask.ts @@ -73,7 +73,10 @@ export interface ValidatedPickingData { sourcePosition?: [number, number]; targetPosition?: [number, number]; path?: string; - geometry?: any; + geometry?: { + type: string; + coordinates: number[] | number[][] | number[][][]; + }; } const getFiltersBySpatialType = ({ @@ -96,7 +99,7 @@ const getFiltersBySpatialType = ({ type, delimiter, } = spatialData; - let values: any[] = []; + let values: (string | number | [number, number] | [number, number][])[] = []; let filters: QueryObjectFilterClause[] = []; let customColumnLabel; diff --git a/superset/views/base.py b/superset/views/base.py index 1e6e12d2cc5..b07d63f5cbb 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -120,6 +120,7 @@ FRONTEND_CONF_KEYS = ( "SQLLAB_QUERY_RESULT_TIMEOUT", "SYNC_DB_PERMISSIONS_IN_ASYNC_MODE", "TABLE_VIZ_MAX_ROW_SERVER", + "MAPBOX_API_KEY", ) logger = logging.getLogger(__name__) diff --git a/superset/views/core.py b/superset/views/core.py index c4955942167..d52a01f3566 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -169,7 +169,9 @@ class Superset(BaseSupersetView): return json_error_response(payload=payload, status=400) return self.json_response( { - "data": payload["df"].to_dict("records"), + "data": payload["df"].to_dict("records") + if payload["df"] is not None + else [], "colnames": payload.get("colnames"), "coltypes": payload.get("coltypes"), "rowcount": payload.get("rowcount"),