This is an automated email from the ASF dual-hosted git repository. rusackas pushed a commit to branch feat/glyph-single-file in repository https://gitbox.apache.org/repos/asf/superset.git
commit 5a331cf81829ea959d36fc4dc83ffa09b0d71b81 Author: Evan Rusackas <[email protected]> AuthorDate: Mon May 25 19:19:37 2026 -0700 feat(glyph): consolidate plugin-chart-point-cluster-map to defineChart() Fold the legacy multi-file plugin (index.ts + controlPanel.ts + transformProps.ts + buildQuery.ts) into a single src/index.tsx using defineChart(). MapLibre.tsx component and helpers stay separate. - arguments: {} (legacy chart, all controls inline via prependSections) - suppressQuerySection: true (own Query section) - additionalControlOverrides: groupby description preserved - formDataOverrides: getStandardizedControls().popAllColumns() preserved 41 plugin tests still pass. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --- .../src/buildQuery.ts | 90 --- .../src/controlPanel.ts | 396 ----------- .../plugin-chart-point-cluster-map/src/index.ts | 59 -- .../plugin-chart-point-cluster-map/src/index.tsx | 783 +++++++++++++++++++++ .../src/transformProps.ts | 292 -------- 5 files changed, 783 insertions(+), 837 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/buildQuery.ts deleted file mode 100644 index 6bc9a72cc92..00000000000 --- a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/buildQuery.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 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, - QueryFormColumn, - QueryObject, - QueryObjectFilterClause, - SqlaFormData, -} from '@superset-ui/core'; - -export interface MapLibreFormData extends SqlaFormData { - all_columns_x?: string; - all_columns_y?: string; - map_label?: string[]; - point_radius?: string; - clustering_radius?: string; - pandas_aggfunc?: string; - global_opacity?: number; - maplibre_style?: string; - mapbox_style?: string; - map_color?: string; - render_while_dragging?: boolean; - point_radius_unit?: string; -} - -export default function buildQuery(formData: MapLibreFormData) { - const { all_columns_x, all_columns_y, map_label, point_radius } = formData; - - if (!all_columns_x || !all_columns_y) { - throw new Error('Longitude and latitude columns are required'); - } - - return buildQueryContext(formData, (baseQueryObject: QueryObject) => { - const columns: QueryFormColumn[] = [ - ...ensureIsArray(baseQueryObject.columns || []), - all_columns_x, - all_columns_y, - ]; - - // Add label column if specified and not 'count' - const hasCustomMetric = - map_label && map_label.length > 0 && map_label[0] !== 'count'; - if (hasCustomMetric) { - columns.push(map_label[0]); - } - - // Add point radius column if not "Auto" - if (point_radius && point_radius !== 'Auto') { - columns.push(point_radius); - } - - // Add null filters for lon/lat - const filters: QueryObjectFilterClause[] = ensureIsArray( - baseQueryObject.filters || [], - ); - filters.push( - { col: all_columns_x, op: 'IS NOT NULL' }, - { col: all_columns_y, op: 'IS NOT NULL' }, - ); - - // Deduplicate columns - const uniqueColumns = [...new Set(columns)]; - - return [ - { - ...baseQueryObject, - columns: uniqueColumns, - filters, - is_timeseries: false, - }, - ]; - }); -} diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/controlPanel.ts b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/controlPanel.ts deleted file mode 100644 index 294e4f12087..00000000000 --- a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/controlPanel.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { t } from '@apache-superset/core/translation'; -import { - columnChoices, - ControlPanelConfig, - formatSelectOptions, - sharedControls, - getStandardizedControls, -} from '@superset-ui/chart-controls'; - -const columnsConfig = sharedControls.entity; - -const colorChoices = [ - ['#008b8b', t('Dark Cyan')], - ['#800080', t('Purple')], - ['#ffd700', t('Gold')], - ['#454545', t('Dim Gray')], - ['#dc143c', t('Crimson')], - ['#228b22', t('Forest Green')], -]; - -const config: ControlPanelConfig = { - controlPanelSections: [ - { - label: t('Query'), - expanded: true, - controlSetRows: [ - [ - { - name: 'all_columns_x', - config: { - ...columnsConfig, - label: t('Longitude'), - description: t('Column containing longitude data'), - }, - }, - ], - [ - { - name: 'all_columns_y', - config: { - ...columnsConfig, - label: t('Latitude'), - description: t('Column containing latitude data'), - }, - }, - ], - [ - { - name: 'clustering_radius', - config: { - type: 'SelectControl', - freeForm: true, - label: t('Clustering Radius'), - default: '60', - choices: formatSelectOptions([ - '0', - '20', - '40', - '60', - '80', - '100', - '200', - '500', - '1000', - ]), - description: t( - 'The radius (in pixels) the algorithm uses to define a cluster. ' + - 'Choose 0 to turn off clustering, but beware that a large ' + - 'number of points (>1000) will cause lag.', - ), - }, - }, - ], - ['row_limit'], - ['adhoc_filters'], - ['groupby'], - ], - }, - { - label: t('Points'), - controlSetRows: [ - [ - { - name: 'point_radius', - config: { - type: 'SelectControl', - label: t('Point Radius'), - default: 'Auto', - description: t( - 'The radius of individual points (ones that are not in a cluster). ' + - 'Either a numerical column or `Auto`, which scales the point based ' + - 'on the largest cluster', - ), - mapStateToProps: (state: any) => { - const datasourceChoices = columnChoices(state.datasource); - const choices: [string, string][] = [['Auto', t('Auto')]]; - return { - choices: choices.concat(datasourceChoices), - }; - }, - }, - }, - ], - [ - { - name: 'point_radius_unit', - config: { - type: 'SelectControl', - label: t('Point Radius Unit'), - default: 'Pixels', - choices: [ - ['Pixels', t('Pixels')], - ['Miles', t('Miles')], - ['Kilometers', t('Kilometers')], - ], - description: t( - 'The unit of measure for the specified point radius', - ), - }, - }, - ], - ], - }, - { - label: t('Labelling'), - controlSetRows: [ - [ - { - name: 'map_label', - config: { - type: 'SelectControl', - multi: true, - label: t('label'), - default: [], - description: t( - '`count` is COUNT(*) if a group by is used. ' + - 'Numerical columns will be aggregated with the aggregator. ' + - 'Non-numerical columns will be used to label points. ' + - 'Leave empty to get a count of points in each cluster.', - ), - mapStateToProps: (state: any) => ({ - choices: columnChoices(state.datasource), - }), - }, - }, - ], - [ - { - name: 'pandas_aggfunc', - config: { - type: 'SelectControl', - label: t('Cluster label aggregator'), - clearable: false, - choices: [ - ['sum', t('sum')], - ['mean', t('mean')], - ['min', t('min')], - ['max', t('max')], - ['std', t('std')], - ['var', t('var')], - ], - default: 'sum', - description: t( - 'Aggregate function applied to the list of points ' + - 'in each cluster to produce the cluster label.', - ), - }, - }, - ], - ], - }, - { - label: t('Map'), - tabOverride: 'customize', - expanded: true, - controlSetRows: [ - [ - { - name: 'map_renderer', - config: { - type: 'SelectControl', - label: t('Map Renderer'), - clearable: false, - renderTrigger: true, - choices: [ - ['maplibre', t('MapLibre (open-source)')], - ['mapbox', t('Mapbox (API key required)')], - ], - default: 'maplibre', - description: t( - 'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.', - ), - }, - }, - ], - [ - { - name: 'maplibre_style', - config: { - type: 'SelectControl', - label: t('Map Style'), - clearable: false, - renderTrigger: true, - freeForm: true, - choices: [ - [ - 'https://tiles.openfreemap.org/styles/liberty', - t('Liberty (OpenFreeMap)'), - ], - [ - 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', - t('Light (Carto)'), - ], - [ - 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', - t('Dark (Carto)'), - ], - [ - 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', - t('Streets (Carto)'), - ], - ], - default: 'https://tiles.openfreemap.org/styles/liberty', - description: t( - 'Base layer map style. See MapLibre documentation: %s', - 'https://maplibre.org/maplibre-style-spec/', - ), - visibility: ({ controls }: any) => - controls?.map_renderer?.value !== 'mapbox', - }, - }, - ], - [ - { - name: 'mapbox_style', - config: { - type: 'SelectControl', - label: t('Map Style'), - clearable: false, - renderTrigger: true, - freeForm: true, - choices: [ - ['mapbox://styles/mapbox/streets-v12', t('Streets')], - ['mapbox://styles/mapbox/outdoors-v12', t('Outdoors')], - ['mapbox://styles/mapbox/light-v11', t('Light')], - ['mapbox://styles/mapbox/dark-v11', t('Dark')], - ['mapbox://styles/mapbox/satellite-v9', t('Satellite')], - [ - 'mapbox://styles/mapbox/satellite-streets-v12', - t('Satellite Streets'), - ], - ], - default: 'mapbox://styles/mapbox/light-v11', - description: t( - 'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).', - ), - visibility: ({ controls }: any) => - controls?.map_renderer?.value === 'mapbox', - }, - }, - ], - ], - }, - { - label: t('Visual Tweaks'), - tabOverride: 'customize', - controlSetRows: [ - [ - { - name: 'render_while_dragging', - config: { - type: 'CheckboxControl', - label: t('Live render'), - renderTrigger: true, - default: true, - description: t( - 'Points and clusters will update as the viewport is being changed', - ), - }, - }, - ], - [ - { - name: 'global_opacity', - config: { - type: 'TextControl', - label: t('Opacity'), - renderTrigger: true, - default: 1, - isFloat: true, - description: t( - 'Opacity of all clusters, points, and labels. Between 0 and 1.', - ), - }, - }, - ], - [ - { - name: 'map_color', - config: { - type: 'SelectControl', - freeForm: true, - renderTrigger: true, - label: t('RGB Color'), - default: colorChoices[0][0], - choices: colorChoices, - description: t('The color for points and clusters in RGB'), - }, - }, - ], - ], - }, - { - label: t('Viewport'), - expanded: true, - controlSetRows: [ - [ - { - name: 'viewport_longitude', - config: { - type: 'TextControl', - label: t('Default longitude'), - renderTrigger: true, - default: '', - isFloat: true, - description: t('Longitude of default viewport'), - places: 8, - dontRefreshOnChange: true, - }, - }, - { - name: 'viewport_latitude', - config: { - type: 'TextControl', - label: t('Default latitude'), - renderTrigger: true, - default: '', - isFloat: true, - description: t('Latitude of default viewport'), - places: 8, - dontRefreshOnChange: true, - }, - }, - ], - [ - { - name: 'viewport_zoom', - config: { - type: 'TextControl', - label: t('Zoom'), - renderTrigger: true, - isFloat: true, - default: '', - description: t('Zoom level of the map'), - places: 8, - dontRefreshOnChange: true, - }, - }, - null, - ], - ], - }, - ], - controlOverrides: { - groupby: { - description: t( - 'One or many controls to group by. If grouping, latitude ' + - 'and longitude columns must be present.', - ), - }, - }, - formDataOverrides: (formData: any) => ({ - ...formData, - groupby: getStandardizedControls().popAllColumns(), - }), -}; - -export default config; diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/index.ts b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/index.ts deleted file mode 100644 index 1f95c36a98d..00000000000 --- a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { t } from '@apache-superset/core/translation'; -import { ChartMetadata, ChartPlugin } from '@superset-ui/core'; -import thumbnail from './images/thumbnail.png'; -import thumbnailDark from './images/thumbnail-dark.png'; -import example1 from './images/MapBox.jpg'; -import example1Dark from './images/MapBox-dark.jpg'; -import example2 from './images/MapBox2.jpg'; -import example2Dark from './images/MapBox2-dark.jpg'; -import controlPanel from './controlPanel'; - -const metadata = new ChartMetadata({ - category: t('Map'), - credits: ['https://maplibre.org/'], - description: '', - exampleGallery: [ - { url: example1, urlDark: example1Dark, caption: t('Light mode') }, - { url: example2, urlDark: example2Dark, caption: t('Dark mode') }, - ], - name: t('Point Cluster Map'), - tags: [ - t('Business'), - t('Intensity'), - t('Density'), - t('Scatter'), - t('Transformable'), - ], - thumbnail, - thumbnailDark, -}); - -export default class ScatterMapChartPlugin extends ChartPlugin { - constructor() { - super({ - loadChart: () => import('./MapLibre'), - loadTransformProps: () => import('./transformProps'), - loadBuildQuery: () => import('./buildQuery'), - metadata, - controlPanel, - }); - } -} diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/index.tsx b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/index.tsx new file mode 100644 index 00000000000..73752ee05f0 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/index.tsx @@ -0,0 +1,783 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { t } from '@apache-superset/core/translation'; +import { + buildQueryContext, + ChartProps, + ensureIsArray, + QueryFormColumn, + QueryObject, + QueryObjectFilterClause, + SqlaFormData, +} from '@superset-ui/core'; +import { + columnChoices, + formatSelectOptions, + sharedControls, + getStandardizedControls, +} from '@superset-ui/chart-controls'; +import { defineChart } from '@superset-ui/glyph-core'; +import Supercluster, { + type Options as SuperclusterOptions, +} from 'supercluster'; +import MapLibre, { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapLibre'; +import roundDecimal from './utils/roundDecimal'; +import thumbnail from './images/thumbnail.png'; +import thumbnailDark from './images/thumbnail-dark.png'; +import example1 from './images/MapBox.jpg'; +import example1Dark from './images/MapBox-dark.jpg'; +import example2 from './images/MapBox2.jpg'; +import example2Dark from './images/MapBox2-dark.jpg'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface MapLibreFormData extends SqlaFormData { + all_columns_x?: string; + all_columns_y?: string; + map_label?: string[]; + point_radius?: string; + clustering_radius?: string; + pandas_aggfunc?: string; + global_opacity?: number; + maplibre_style?: string; + mapbox_style?: string; + map_color?: string; + render_while_dragging?: boolean; + point_radius_unit?: string; +} + +// ─── buildQuery ────────────────────────────────────────────────────────────── + +export function buildQuery(formData: MapLibreFormData) { + const { all_columns_x, all_columns_y, map_label, point_radius } = formData; + + if (!all_columns_x || !all_columns_y) { + throw new Error('Longitude and latitude columns are required'); + } + + return buildQueryContext(formData, (baseQueryObject: QueryObject) => { + const columns: QueryFormColumn[] = [ + ...ensureIsArray(baseQueryObject.columns || []), + all_columns_x, + all_columns_y, + ]; + + // Add label column if specified and not 'count' + const hasCustomMetric = + map_label && map_label.length > 0 && map_label[0] !== 'count'; + if (hasCustomMetric) { + columns.push(map_label[0]); + } + + // Add point radius column if not "Auto" + if (point_radius && point_radius !== 'Auto') { + columns.push(point_radius); + } + + // Add null filters for lon/lat + const filters: QueryObjectFilterClause[] = ensureIsArray( + baseQueryObject.filters || [], + ); + filters.push( + { col: all_columns_x, op: 'IS NOT NULL' }, + { col: all_columns_y, op: 'IS NOT NULL' }, + ); + + // Deduplicate columns + const uniqueColumns = [...new Set(columns)]; + + return [ + { + ...baseQueryObject, + columns: uniqueColumns, + filters, + is_timeseries: false, + }, + ]; + }); +} + +// ─── transformProps ────────────────────────────────────────────────────────── + +const NOOP = () => {}; + +// Geo precision to limit decimal places (matching legacy backend behavior) +const GEO_PRECISION = 10; + +const MIN_LONGITUDE = -180; +const MAX_LONGITUDE = 180; +const MIN_LATITUDE = -90; +const MAX_LATITUDE = 90; +const MIN_ZOOM = 0; + +function toFiniteNumber( + value: string | number | null | undefined, +): number | undefined { + if (value === null || value === undefined) return undefined; + const normalizedValue = typeof value === 'string' ? value.trim() : value; + if (normalizedValue === '') return undefined; + const num = Number(normalizedValue); + return Number.isFinite(num) ? num : undefined; +} + +function clampNumber( + value: number | undefined, + min: number, + max: number, +): number | undefined { + if (value === undefined) return undefined; + return Math.min(max, Math.max(min, value)); +} + +interface PointProperties { + metric: number | string | null; + radius: number | string | null; +} + +interface ClusterProperties { + metric: number; + sum: number; + squaredSum: number; + min: number; + max: number; +} + +interface DataRecord { + [key: string]: string | number | null | undefined; +} + +function buildGeoJSONFromRecords( + records: DataRecord[], + lonCol: string, + latCol: string, + labelCol: string | null, + pointRadiusCol: string | null, +) { + const features: GeoJSON.Feature<GeoJSON.Point, PointProperties>[] = []; + let minLon = Infinity; + let maxLon = -Infinity; + let minLat = Infinity; + let maxLat = -Infinity; + + for (const record of records) { + const rawLon = record[lonCol]; + const rawLat = record[latCol]; + if (rawLon == null || rawLat == null) { + continue; + } + const lon = Number(rawLon); + const lat = Number(rawLat); + if (!Number.isFinite(lon) || !Number.isFinite(lat)) { + continue; + } + + const roundedLon = roundDecimal(lon, GEO_PRECISION); + const roundedLat = roundDecimal(lat, GEO_PRECISION); + + minLon = Math.min(minLon, roundedLon); + maxLon = Math.max(maxLon, roundedLon); + minLat = Math.min(minLat, roundedLat); + maxLat = Math.max(maxLat, roundedLat); + + const metric = labelCol != null ? (record[labelCol] ?? null) : null; + const radius = + pointRadiusCol != null ? (record[pointRadiusCol] ?? null) : null; + + features.push({ + type: 'Feature', + properties: { metric, radius }, + geometry: { + type: 'Point', + coordinates: [roundedLon, roundedLat], + }, + }); + } + + const bounds: [[number, number], [number, number]] | undefined = + features.length > 0 + ? [ + [minLon, minLat], + [maxLon, maxLat], + ] + : undefined; + + return { + geoJSON: { type: 'FeatureCollection' as const, features }, + bounds, + }; +} + +export function transformProps(chartProps: ChartProps) { + const { + width, + height, + rawFormData: formData, + hooks, + queriesData, + } = chartProps; + const { onError = NOOP, setControlValue = NOOP } = hooks; + + const { + all_columns_x: allColumnsX, + all_columns_y: allColumnsY, + clustering_radius: clusteringRadius, + global_opacity: globalOpacity, + map_color: maplibreColor, + map_label: maplibreLabel, + map_renderer: mapProvider, + maplibre_style: maplibreStyle, + mapbox_style: mapboxStyle = '', + pandas_aggfunc: pandasAggfunc, + point_radius: pointRadius, + point_radius_unit: pointRadiusUnit, + render_while_dragging: renderWhileDragging, + viewport_longitude: viewportLongitude, + viewport_latitude: viewportLatitude, + viewport_zoom: viewportZoom, + } = formData; + + // Support two data formats: + // 1. Legacy/GeoJSON: queriesData[0].data is an object with { geoJSON, bounds, hasCustomMetric } + // 2. Tabular records: queriesData[0].data is an array of flat records from a SQL query + const rawData = queriesData[0]?.data; + const isLegacyFormat = rawData && !Array.isArray(rawData) && rawData.geoJSON; + + let geoJSON: { type: 'FeatureCollection'; features: any[] }; + let bounds: [[number, number], [number, number]] | undefined; + let hasCustomMetric: boolean; + + if (isLegacyFormat) { + const legacy = rawData as any; + ({ geoJSON } = legacy); + ({ bounds } = legacy); + hasCustomMetric = legacy.hasCustomMetric ?? false; + } else { + const records: DataRecord[] = (rawData as DataRecord[]) || []; + hasCustomMetric = + maplibreLabel != null && + maplibreLabel.length > 0 && + maplibreLabel[0] !== 'count'; + const labelCol = hasCustomMetric ? maplibreLabel[0] : null; + const pointRadiusCol = + pointRadius && pointRadius !== 'Auto' ? pointRadius : null; + + const built = buildGeoJSONFromRecords( + records, + allColumnsX, + allColumnsY, + labelCol, + pointRadiusCol, + ); + ({ geoJSON } = built); + ({ bounds } = built); + } + + // Validate color — supports hex (#rrggbb) and rgb(r, g, b) formats + let rgb: string[] | null = null; + const hexMatch = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec( + maplibreColor, + ); + if (hexMatch) { + rgb = [ + maplibreColor, + String(parseInt(hexMatch[1], 16)), + String(parseInt(hexMatch[2], 16)), + String(parseInt(hexMatch[3], 16)), + ]; + } else { + rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(maplibreColor); + } + if (rgb === null) { + onError(t("Color field must be a hex color (#rrggbb) or 'rgb(r, g, b)'")); + // Fall back to a safe default color so the chart can still render + rgb = ['', '0', '0', '0']; + } + + const opts: SuperclusterOptions<PointProperties, ClusterProperties> = { + maxZoom: DEFAULT_MAX_ZOOM, + radius: clusteringRadius, + }; + if (hasCustomMetric) { + opts.map = (prop: PointProperties) => ({ + metric: Number(prop.metric) || 0, + sum: Number(prop.metric) || 0, + squaredSum: (Number(prop.metric) || 0) ** 2, + min: Number(prop.metric) || 0, + max: Number(prop.metric) || 0, + }); + opts.reduce = (accu: ClusterProperties, prop: ClusterProperties) => { + /* eslint-disable no-param-reassign */ + accu.sum += prop.sum; + accu.squaredSum += prop.squaredSum; + accu.min = Math.min(accu.min, prop.min); + accu.max = Math.max(accu.max, prop.max); + /* eslint-enable no-param-reassign */ + }; + } + const clusterer = new Supercluster<PointProperties, ClusterProperties>(opts); + // Disable strict typecheck on load since Supercluster typings have namespace issues with esModuleInterop + clusterer.load(geoJSON.features as any); + + return { + width, + height, + aggregatorName: pandasAggfunc, + bounds, + clusterer, + globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)), + hasCustomMetric, + mapProvider, + mapStyle: + mapProvider === 'mapbox' + ? (mapboxStyle as string) + : (maplibreStyle as string), + onViewportChange({ + latitude, + longitude, + zoom, + }: { + latitude: number; + longitude: number; + zoom: number; + }) { + setControlValue('viewport_longitude', longitude); + setControlValue('viewport_latitude', latitude); + setControlValue('viewport_zoom', zoom); + }, + pointRadius: DEFAULT_POINT_RADIUS, + pointRadiusUnit, + renderWhileDragging, + rgb, + viewportLongitude: clampNumber( + toFiniteNumber(viewportLongitude), + MIN_LONGITUDE, + MAX_LONGITUDE, + ), + viewportLatitude: clampNumber( + toFiniteNumber(viewportLatitude), + MIN_LATITUDE, + MAX_LATITUDE, + ), + viewportZoom: clampNumber( + toFiniteNumber(viewportZoom), + MIN_ZOOM, + DEFAULT_MAX_ZOOM, + ), + }; +} + +// ─── Control panel pieces ──────────────────────────────────────────────────── + +const columnsConfig = sharedControls.entity; + +// eslint-disable-next-line theme-colors/no-literal-colors +const colorChoices = [ + ['#008b8b', t('Dark Cyan')], + ['#800080', t('Purple')], + ['#ffd700', t('Gold')], + ['#454545', t('Dim Gray')], + ['#dc143c', t('Crimson')], + ['#228b22', t('Forest Green')], +]; + +// ─── Plugin definition ─────────────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default defineChart<Record<string, never>, any>({ + metadata: { + category: t('Map'), + credits: ['https://maplibre.org/'], + description: '', + exampleGallery: [ + { url: example1, urlDark: example1Dark, caption: t('Light mode') }, + { url: example2, urlDark: example2Dark, caption: t('Dark mode') }, + ], + name: t('Point Cluster Map'), + tags: [ + t('Business'), + t('Intensity'), + t('Density'), + t('Scatter'), + t('Transformable'), + ], + thumbnail, + thumbnailDark, + }, + arguments: {}, + suppressQuerySection: true, + prependSections: [ + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [ + { + name: 'all_columns_x', + config: { + ...columnsConfig, + label: t('Longitude'), + description: t('Column containing longitude data'), + }, + }, + ], + [ + { + name: 'all_columns_y', + config: { + ...columnsConfig, + label: t('Latitude'), + description: t('Column containing latitude data'), + }, + }, + ], + [ + { + name: 'clustering_radius', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Clustering Radius'), + default: '60', + choices: formatSelectOptions([ + '0', + '20', + '40', + '60', + '80', + '100', + '200', + '500', + '1000', + ]), + description: t( + 'The radius (in pixels) the algorithm uses to define a cluster. ' + + 'Choose 0 to turn off clustering, but beware that a large ' + + 'number of points (>1000) will cause lag.', + ), + }, + }, + ], + ['row_limit'], + ['adhoc_filters'], + ['groupby'], + ], + }, + { + label: t('Points'), + controlSetRows: [ + [ + { + name: 'point_radius', + config: { + type: 'SelectControl', + label: t('Point Radius'), + default: 'Auto', + description: t( + 'The radius of individual points (ones that are not in a cluster). ' + + 'Either a numerical column or `Auto`, which scales the point based ' + + 'on the largest cluster', + ), + mapStateToProps: (state: any) => { + const datasourceChoices = columnChoices(state.datasource); + const choices: [string, string][] = [['Auto', t('Auto')]]; + return { + choices: choices.concat(datasourceChoices), + }; + }, + }, + }, + ], + [ + { + name: 'point_radius_unit', + config: { + type: 'SelectControl', + label: t('Point Radius Unit'), + default: 'Pixels', + choices: [ + ['Pixels', t('Pixels')], + ['Miles', t('Miles')], + ['Kilometers', t('Kilometers')], + ], + description: t( + 'The unit of measure for the specified point radius', + ), + }, + }, + ], + ], + }, + { + label: t('Labelling'), + controlSetRows: [ + [ + { + name: 'map_label', + config: { + type: 'SelectControl', + multi: true, + label: t('label'), + default: [], + description: t( + '`count` is COUNT(*) if a group by is used. ' + + 'Numerical columns will be aggregated with the aggregator. ' + + 'Non-numerical columns will be used to label points. ' + + 'Leave empty to get a count of points in each cluster.', + ), + mapStateToProps: (state: any) => ({ + choices: columnChoices(state.datasource), + }), + }, + }, + ], + [ + { + name: 'pandas_aggfunc', + config: { + type: 'SelectControl', + label: t('Cluster label aggregator'), + clearable: false, + choices: [ + ['sum', t('sum')], + ['mean', t('mean')], + ['min', t('min')], + ['max', t('max')], + ['std', t('std')], + ['var', t('var')], + ], + default: 'sum', + description: t( + 'Aggregate function applied to the list of points ' + + 'in each cluster to produce the cluster label.', + ), + }, + }, + ], + ], + }, + { + label: t('Map'), + tabOverride: 'customize', + expanded: true, + controlSetRows: [ + [ + { + name: 'map_renderer', + config: { + type: 'SelectControl', + label: t('Map Renderer'), + clearable: false, + renderTrigger: true, + choices: [ + ['maplibre', t('MapLibre (open-source)')], + ['mapbox', t('Mapbox (API key required)')], + ], + default: 'maplibre', + description: t( + 'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.', + ), + }, + }, + ], + [ + { + name: 'maplibre_style', + config: { + type: 'SelectControl', + label: t('Map Style'), + clearable: false, + renderTrigger: true, + freeForm: true, + choices: [ + [ + 'https://tiles.openfreemap.org/styles/liberty', + t('Liberty (OpenFreeMap)'), + ], + [ + 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', + t('Light (Carto)'), + ], + [ + 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', + t('Dark (Carto)'), + ], + [ + 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', + t('Streets (Carto)'), + ], + ], + default: 'https://tiles.openfreemap.org/styles/liberty', + description: t( + 'Base layer map style. See MapLibre documentation: %s', + 'https://maplibre.org/maplibre-style-spec/', + ), + visibility: ({ controls }: any) => + controls?.map_renderer?.value !== 'mapbox', + }, + }, + ], + [ + { + name: 'mapbox_style', + config: { + type: 'SelectControl', + label: t('Map Style'), + clearable: false, + renderTrigger: true, + freeForm: true, + choices: [ + ['mapbox://styles/mapbox/streets-v12', t('Streets')], + ['mapbox://styles/mapbox/outdoors-v12', t('Outdoors')], + ['mapbox://styles/mapbox/light-v11', t('Light')], + ['mapbox://styles/mapbox/dark-v11', t('Dark')], + ['mapbox://styles/mapbox/satellite-v9', t('Satellite')], + [ + 'mapbox://styles/mapbox/satellite-streets-v12', + t('Satellite Streets'), + ], + ], + default: 'mapbox://styles/mapbox/light-v11', + description: t( + 'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).', + ), + visibility: ({ controls }: any) => + controls?.map_renderer?.value === 'mapbox', + }, + }, + ], + ], + }, + { + label: t('Visual Tweaks'), + tabOverride: 'customize', + controlSetRows: [ + [ + { + name: 'render_while_dragging', + config: { + type: 'CheckboxControl', + label: t('Live render'), + renderTrigger: true, + default: true, + description: t( + 'Points and clusters will update as the viewport is being changed', + ), + }, + }, + ], + [ + { + name: 'global_opacity', + config: { + type: 'TextControl', + label: t('Opacity'), + renderTrigger: true, + default: 1, + isFloat: true, + description: t( + 'Opacity of all clusters, points, and labels. Between 0 and 1.', + ), + }, + }, + ], + [ + { + name: 'map_color', + config: { + type: 'SelectControl', + freeForm: true, + renderTrigger: true, + label: t('RGB Color'), + default: colorChoices[0][0], + choices: colorChoices, + description: t('The color for points and clusters in RGB'), + }, + }, + ], + ], + }, + { + label: t('Viewport'), + expanded: true, + controlSetRows: [ + [ + { + name: 'viewport_longitude', + config: { + type: 'TextControl', + label: t('Default longitude'), + renderTrigger: true, + default: '', + isFloat: true, + description: t('Longitude of default viewport'), + places: 8, + dontRefreshOnChange: true, + }, + }, + { + name: 'viewport_latitude', + config: { + type: 'TextControl', + label: t('Default latitude'), + renderTrigger: true, + default: '', + isFloat: true, + description: t('Latitude of default viewport'), + places: 8, + dontRefreshOnChange: true, + }, + }, + ], + [ + { + name: 'viewport_zoom', + config: { + type: 'TextControl', + label: t('Zoom'), + renderTrigger: true, + isFloat: true, + default: '', + description: t('Zoom level of the map'), + places: 8, + dontRefreshOnChange: true, + }, + }, + null, + ], + ], + }, + ], + additionalControlOverrides: { + groupby: { + description: t( + 'One or many controls to group by. If grouping, latitude ' + + 'and longitude columns must be present.', + ), + }, + }, + formDataOverrides: (formData: any) => ({ + ...formData, + groupby: getStandardizedControls().popAllColumns(), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + buildQuery: (formData: any) => buildQuery(formData as MapLibreFormData), + transform: chartProps => transformProps(chartProps), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + render: ({ transformedProps }) => <MapLibre {...(transformedProps as any)} />, +}); diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/transformProps.ts deleted file mode 100644 index 04d0cae20a8..00000000000 --- a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/transformProps.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * 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 Supercluster, { - type Options as SuperclusterOptions, -} from 'supercluster'; -import { ChartProps } from '@superset-ui/core'; -import { t } from '@apache-superset/core/translation'; -import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapLibre'; -import roundDecimal from './utils/roundDecimal'; - -const NOOP = () => {}; - -// Geo precision to limit decimal places (matching legacy backend behavior) -const GEO_PRECISION = 10; - -const MIN_LONGITUDE = -180; -const MAX_LONGITUDE = 180; -const MIN_LATITUDE = -90; -const MAX_LATITUDE = 90; -const MIN_ZOOM = 0; - -function toFiniteNumber( - value: string | number | null | undefined, -): number | undefined { - if (value === null || value === undefined) return undefined; - const normalizedValue = typeof value === 'string' ? value.trim() : value; - if (normalizedValue === '') return undefined; - const num = Number(normalizedValue); - return Number.isFinite(num) ? num : undefined; -} - -function clampNumber( - value: number | undefined, - min: number, - max: number, -): number | undefined { - if (value === undefined) return undefined; - return Math.min(max, Math.max(min, value)); -} - -interface PointProperties { - metric: number | string | null; - radius: number | string | null; -} - -interface ClusterProperties { - metric: number; - sum: number; - squaredSum: number; - min: number; - max: number; -} - -interface DataRecord { - [key: string]: string | number | null | undefined; -} - -function buildGeoJSONFromRecords( - records: DataRecord[], - lonCol: string, - latCol: string, - labelCol: string | null, - pointRadiusCol: string | null, -) { - const features: GeoJSON.Feature<GeoJSON.Point, PointProperties>[] = []; - let minLon = Infinity; - let maxLon = -Infinity; - let minLat = Infinity; - let maxLat = -Infinity; - - for (const record of records) { - const rawLon = record[lonCol]; - const rawLat = record[latCol]; - if (rawLon == null || rawLat == null) { - continue; - } - const lon = Number(rawLon); - const lat = Number(rawLat); - if (!Number.isFinite(lon) || !Number.isFinite(lat)) { - continue; - } - - const roundedLon = roundDecimal(lon, GEO_PRECISION); - const roundedLat = roundDecimal(lat, GEO_PRECISION); - - minLon = Math.min(minLon, roundedLon); - maxLon = Math.max(maxLon, roundedLon); - minLat = Math.min(minLat, roundedLat); - maxLat = Math.max(maxLat, roundedLat); - - const metric = labelCol != null ? (record[labelCol] ?? null) : null; - const radius = - pointRadiusCol != null ? (record[pointRadiusCol] ?? null) : null; - - features.push({ - type: 'Feature', - properties: { metric, radius }, - geometry: { - type: 'Point', - coordinates: [roundedLon, roundedLat], - }, - }); - } - - const bounds: [[number, number], [number, number]] | undefined = - features.length > 0 - ? [ - [minLon, minLat], - [maxLon, maxLat], - ] - : undefined; - - return { - geoJSON: { type: 'FeatureCollection' as const, features }, - bounds, - }; -} - -export default function transformProps(chartProps: ChartProps) { - const { - width, - height, - rawFormData: formData, - hooks, - queriesData, - } = chartProps; - const { onError = NOOP, setControlValue = NOOP } = hooks; - - const { - all_columns_x: allColumnsX, - all_columns_y: allColumnsY, - clustering_radius: clusteringRadius, - global_opacity: globalOpacity, - map_color: maplibreColor, - map_label: maplibreLabel, - map_renderer: mapProvider, - maplibre_style: maplibreStyle, - mapbox_style: mapboxStyle = '', - pandas_aggfunc: pandasAggfunc, - point_radius: pointRadius, - point_radius_unit: pointRadiusUnit, - render_while_dragging: renderWhileDragging, - viewport_longitude: viewportLongitude, - viewport_latitude: viewportLatitude, - viewport_zoom: viewportZoom, - } = formData; - - // Support two data formats: - // 1. Legacy/GeoJSON: queriesData[0].data is an object with { geoJSON, bounds, hasCustomMetric } - // 2. Tabular records: queriesData[0].data is an array of flat records from a SQL query - const rawData = queriesData[0]?.data; - const isLegacyFormat = rawData && !Array.isArray(rawData) && rawData.geoJSON; - - let geoJSON: { type: 'FeatureCollection'; features: any[] }; - let bounds: [[number, number], [number, number]] | undefined; - let hasCustomMetric: boolean; - - if (isLegacyFormat) { - const legacy = rawData as any; - ({ geoJSON } = legacy); - ({ bounds } = legacy); - hasCustomMetric = legacy.hasCustomMetric ?? false; - } else { - const records: DataRecord[] = (rawData as DataRecord[]) || []; - hasCustomMetric = - maplibreLabel != null && - maplibreLabel.length > 0 && - maplibreLabel[0] !== 'count'; - const labelCol = hasCustomMetric ? maplibreLabel[0] : null; - const pointRadiusCol = - pointRadius && pointRadius !== 'Auto' ? pointRadius : null; - - const built = buildGeoJSONFromRecords( - records, - allColumnsX, - allColumnsY, - labelCol, - pointRadiusCol, - ); - ({ geoJSON } = built); - ({ bounds } = built); - } - - // Validate color — supports hex (#rrggbb) and rgb(r, g, b) formats - let rgb: string[] | null = null; - const hexMatch = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec( - maplibreColor, - ); - if (hexMatch) { - rgb = [ - maplibreColor, - String(parseInt(hexMatch[1], 16)), - String(parseInt(hexMatch[2], 16)), - String(parseInt(hexMatch[3], 16)), - ]; - } else { - rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(maplibreColor); - } - if (rgb === null) { - onError(t("Color field must be a hex color (#rrggbb) or 'rgb(r, g, b)'")); - // Fall back to a safe default color so the chart can still render - rgb = ['', '0', '0', '0']; - } - - const opts: SuperclusterOptions<PointProperties, ClusterProperties> = { - maxZoom: DEFAULT_MAX_ZOOM, - radius: clusteringRadius, - }; - if (hasCustomMetric) { - opts.map = (prop: PointProperties) => ({ - metric: Number(prop.metric) || 0, - sum: Number(prop.metric) || 0, - squaredSum: (Number(prop.metric) || 0) ** 2, - min: Number(prop.metric) || 0, - max: Number(prop.metric) || 0, - }); - opts.reduce = (accu: ClusterProperties, prop: ClusterProperties) => { - /* eslint-disable no-param-reassign */ - accu.sum += prop.sum; - accu.squaredSum += prop.squaredSum; - accu.min = Math.min(accu.min, prop.min); - accu.max = Math.max(accu.max, prop.max); - /* eslint-enable no-param-reassign */ - }; - } - const clusterer = new Supercluster<PointProperties, ClusterProperties>(opts); - // Disable strict typecheck on load since Supercluster typings have namespace issues with esModuleInterop - clusterer.load(geoJSON.features as any); - - return { - width, - height, - aggregatorName: pandasAggfunc, - bounds, - clusterer, - globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)), - hasCustomMetric, - mapProvider, - mapStyle: - mapProvider === 'mapbox' - ? (mapboxStyle as string) - : (maplibreStyle as string), - onViewportChange({ - latitude, - longitude, - zoom, - }: { - latitude: number; - longitude: number; - zoom: number; - }) { - setControlValue('viewport_longitude', longitude); - setControlValue('viewport_latitude', latitude); - setControlValue('viewport_zoom', zoom); - }, - pointRadius: DEFAULT_POINT_RADIUS, - pointRadiusUnit, - renderWhileDragging, - rgb, - viewportLongitude: clampNumber( - toFiniteNumber(viewportLongitude), - MIN_LONGITUDE, - MAX_LONGITUDE, - ), - viewportLatitude: clampNumber( - toFiniteNumber(viewportLatitude), - MIN_LATITUDE, - MAX_LATITUDE, - ), - viewportZoom: clampNumber( - toFiniteNumber(viewportZoom), - MIN_ZOOM, - DEFAULT_MAX_ZOOM, - ), - }; -}
