Compare commits
9 Commits
fix-explor
...
dashboard-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71cccfc6a0 | ||
|
|
5815665cc6 | ||
|
|
6649f35a0d | ||
|
|
5263abdc60 | ||
|
|
c49641538d | ||
|
|
d915e4f3ff | ||
|
|
bad5a35fce | ||
|
|
1bde6f3bfd | ||
|
|
4e0890ee1f |
11724
docs/static/resources/openapi.json
vendored
@@ -48,7 +48,7 @@ dependencies = [
|
||||
"cryptography>=42.0.4, <47.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
"flask>=2.2.5, <4.0.0",
|
||||
"flask-appbuilder>=5.0.2,<6",
|
||||
"flask-appbuilder>=5.2.1, <6.0.0",
|
||||
"flask-caching>=2.1.0, <3",
|
||||
"flask-compress>=1.13, <2.0",
|
||||
"flask-talisman>=1.0.0, <2.0",
|
||||
|
||||
@@ -120,7 +120,7 @@ flask==2.3.3
|
||||
# flask-session
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
flask-appbuilder==5.2.0
|
||||
flask-appbuilder==5.2.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
|
||||
@@ -259,7 +259,7 @@ flask==2.3.3
|
||||
# flask-sqlalchemy
|
||||
# flask-testing
|
||||
# flask-wtf
|
||||
flask-appbuilder==5.2.0
|
||||
flask-appbuilder==5.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
934
superset-frontend/package-lock.json
generated
@@ -128,17 +128,17 @@
|
||||
"@superset-ui/legacy-plugin-chart-chord": "file:./plugins/legacy-plugin-chart-chord",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "file:./plugins/legacy-plugin-chart-country-map",
|
||||
"@superset-ui/legacy-plugin-chart-horizon": "file:./plugins/legacy-plugin-chart-horizon",
|
||||
"@superset-ui/legacy-plugin-chart-map-box": "file:./plugins/legacy-plugin-chart-map-box",
|
||||
"@superset-ui/legacy-plugin-chart-paired-t-test": "file:./plugins/legacy-plugin-chart-paired-t-test",
|
||||
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "file:./plugins/legacy-plugin-chart-parallel-coordinates",
|
||||
"@superset-ui/legacy-plugin-chart-partition": "file:./plugins/legacy-plugin-chart-partition",
|
||||
"@superset-ui/legacy-plugin-chart-rose": "file:./plugins/legacy-plugin-chart-rose",
|
||||
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
|
||||
"@superset-ui/legacy-preset-chart-deckgl": "file:./plugins/legacy-preset-chart-deckgl",
|
||||
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
|
||||
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
|
||||
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
|
||||
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
|
||||
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
|
||||
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
|
||||
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
|
||||
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
|
||||
|
||||
@@ -41,6 +41,7 @@ export enum VizType {
|
||||
LegacyBubble = 'bubble',
|
||||
Line = 'echarts_timeseries_line',
|
||||
MapBox = 'mapbox',
|
||||
PointClusterMap = 'point_cluster_map',
|
||||
MixedTimeseries = 'mixed_timeseries',
|
||||
PairedTTest = 'paired_ttest',
|
||||
ParallelCoordinates = 'para',
|
||||
|
||||
@@ -1,55 +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.
|
||||
-->
|
||||
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.20.0](https://github.com/apache/superset/compare/v2021.41.0...v0.20.0) (2024-09-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
|
||||
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
|
||||
|
||||
### Features
|
||||
|
||||
- apply standardized form data to tier 2 charts ([#20530](https://github.com/apache/superset/issues/20530)) ([de524bc](https://github.com/apache/superset/commit/de524bc59f011fd361dcdb7d35c2cb51f7eba442))
|
||||
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
|
||||
|
||||
# [0.19.0](https://github.com/apache/superset/compare/v2021.41.0...v0.19.0) (2024-09-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
|
||||
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
|
||||
|
||||
### Features
|
||||
|
||||
- apply standardized form data to tier 2 charts ([#20530](https://github.com/apache/superset/issues/20530)) ([de524bc](https://github.com/apache/superset/commit/de524bc59f011fd361dcdb7d35c2cb51f7eba442))
|
||||
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
|
||||
|
||||
# [0.18.0](https://github.com/apache-superset/superset-ui/compare/v0.17.87...v0.18.0) (2021-08-30)
|
||||
|
||||
**Note:** Version bump only for package @superset-ui/legacy-plugin-chart-map-box
|
||||
|
||||
## [0.17.61](https://github.com/apache-superset/superset-ui/compare/v0.17.60...v0.17.61) (2021-07-02)
|
||||
|
||||
**Note:** Version bump only for package @superset-ui/legacy-plugin-chart-map-box
|
||||
@@ -1,52 +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.
|
||||
-->
|
||||
|
||||
## @superset-ui/legacy-plugin-chart-map-box
|
||||
|
||||
[](https://www.npmjs.com/package/@superset-ui/legacy-plugin-chart-map-box)
|
||||
[](https://libraries.io/npm/@superset-ui%2Flegacy-plugin-chart-map-box)
|
||||
|
||||
This plugin provides MapBox for Superset.
|
||||
|
||||
### Usage
|
||||
|
||||
Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to
|
||||
lookup this chart throughout the app.
|
||||
|
||||
```js
|
||||
import MapBoxChartPlugin from '@superset-ui/legacy-plugin-chart-map-box';
|
||||
|
||||
new MapBoxChartPlugin().configure({ key: 'map-box' }).register();
|
||||
```
|
||||
|
||||
Then use it via `SuperChart`. See
|
||||
[storybook](https://apache-superset.github.io/superset-ui-plugins/?selectedKind=plugin-chart-map-box)
|
||||
for more details.
|
||||
|
||||
```js
|
||||
<SuperChart
|
||||
chartType="map-box"
|
||||
width={600}
|
||||
height={600}
|
||||
formData={...}
|
||||
queriesData={[{
|
||||
data: {...},
|
||||
}]}
|
||||
/>
|
||||
```
|
||||
@@ -1,243 +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.
|
||||
*/
|
||||
/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
|
||||
/* eslint-disable react/forbid-prop-types, react/require-default-props */
|
||||
import { Component } from 'react';
|
||||
import MapGL from 'react-map-gl';
|
||||
import { WebMercatorViewport } from '@math.gl/web-mercator';
|
||||
import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
|
||||
import './MapBox.css';
|
||||
|
||||
const NOOP = () => {};
|
||||
export const DEFAULT_MAX_ZOOM = 16;
|
||||
export const DEFAULT_POINT_RADIUS = 60;
|
||||
|
||||
interface Viewport {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
zoom: number;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
interface Clusterer {
|
||||
getClusters(bbox: number[], zoom: number): GeoJSONLocation[];
|
||||
}
|
||||
|
||||
interface GeoJSONLocation {
|
||||
geometry: {
|
||||
coordinates: [number, number];
|
||||
};
|
||||
properties: Record<string, number | string | boolean | null | undefined>;
|
||||
}
|
||||
|
||||
interface MapBoxProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
aggregatorName?: string;
|
||||
clusterer: Clusterer; // Required - used for getClusters()
|
||||
globalOpacity?: number;
|
||||
hasCustomMetric?: boolean;
|
||||
mapStyle?: string;
|
||||
mapboxApiKey: string;
|
||||
onViewportChange?: (viewport: Viewport) => void;
|
||||
pointRadius?: number;
|
||||
pointRadiusUnit?: string;
|
||||
renderWhileDragging?: boolean;
|
||||
rgb?: (string | number)[];
|
||||
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
|
||||
viewportLongitude?: number;
|
||||
viewportLatitude?: number;
|
||||
viewportZoom?: number;
|
||||
}
|
||||
|
||||
interface MapBoxState {
|
||||
viewport: Viewport;
|
||||
}
|
||||
|
||||
const defaultProps: Partial<MapBoxProps> = {
|
||||
width: 400,
|
||||
height: 400,
|
||||
globalOpacity: 1,
|
||||
onViewportChange: NOOP,
|
||||
pointRadius: DEFAULT_POINT_RADIUS,
|
||||
pointRadiusUnit: 'Pixels',
|
||||
};
|
||||
|
||||
class MapBox extends Component<MapBoxProps, MapBoxState> {
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
constructor(props: MapBoxProps) {
|
||||
super(props);
|
||||
|
||||
const fitBounds = this.computeFitBoundsViewport();
|
||||
|
||||
this.state = {
|
||||
viewport: this.mergeViewportWithProps(fitBounds),
|
||||
};
|
||||
this.handleViewportChange = this.handleViewportChange.bind(this);
|
||||
}
|
||||
|
||||
handleViewportChange(viewport: Viewport) {
|
||||
this.setState({ viewport });
|
||||
const { onViewportChange } = this.props;
|
||||
onViewportChange!(viewport);
|
||||
}
|
||||
|
||||
mergeViewportWithProps(
|
||||
fitBounds: Viewport,
|
||||
viewport: Viewport = fitBounds,
|
||||
props: MapBoxProps = this.props,
|
||||
useFitBoundsForUnset = true,
|
||||
): Viewport {
|
||||
const { viewportLongitude, viewportLatitude, viewportZoom } = props;
|
||||
|
||||
return {
|
||||
...viewport,
|
||||
longitude:
|
||||
viewportLongitude ??
|
||||
(useFitBoundsForUnset ? fitBounds.longitude : viewport.longitude),
|
||||
latitude:
|
||||
viewportLatitude ??
|
||||
(useFitBoundsForUnset ? fitBounds.latitude : viewport.latitude),
|
||||
zoom:
|
||||
viewportZoom ?? (useFitBoundsForUnset ? fitBounds.zoom : viewport.zoom),
|
||||
};
|
||||
}
|
||||
|
||||
computeFitBoundsViewport(): Viewport {
|
||||
const { width = 400, height = 400, bounds } = this.props;
|
||||
if (bounds && bounds[0] && bounds[1]) {
|
||||
const mercator = new WebMercatorViewport({ width, height }).fitBounds(
|
||||
bounds,
|
||||
);
|
||||
return {
|
||||
latitude: mercator.latitude,
|
||||
longitude: mercator.longitude,
|
||||
zoom: mercator.zoom,
|
||||
};
|
||||
}
|
||||
return { latitude: 0, longitude: 0, zoom: 1 };
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: MapBoxProps) {
|
||||
const { viewport } = this.state;
|
||||
const fitBoundsInputsChanged =
|
||||
prevProps.width !== this.props.width ||
|
||||
prevProps.height !== this.props.height ||
|
||||
prevProps.bounds !== this.props.bounds;
|
||||
const viewportPropsChanged =
|
||||
prevProps.viewportLongitude !== this.props.viewportLongitude ||
|
||||
prevProps.viewportLatitude !== this.props.viewportLatitude ||
|
||||
prevProps.viewportZoom !== this.props.viewportZoom;
|
||||
|
||||
if (!fitBoundsInputsChanged && !viewportPropsChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fitBounds = this.computeFitBoundsViewport();
|
||||
const nextViewport = this.mergeViewportWithProps(
|
||||
fitBounds,
|
||||
viewport,
|
||||
this.props,
|
||||
fitBoundsInputsChanged || viewportPropsChanged,
|
||||
);
|
||||
|
||||
const viewportChanged =
|
||||
nextViewport.longitude !== viewport.longitude ||
|
||||
nextViewport.latitude !== viewport.latitude ||
|
||||
nextViewport.zoom !== viewport.zoom;
|
||||
|
||||
if (viewportChanged) {
|
||||
this.setState({ viewport: nextViewport });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
aggregatorName,
|
||||
clusterer,
|
||||
globalOpacity,
|
||||
mapStyle,
|
||||
mapboxApiKey,
|
||||
pointRadius,
|
||||
pointRadiusUnit,
|
||||
renderWhileDragging,
|
||||
rgb,
|
||||
hasCustomMetric,
|
||||
bounds,
|
||||
} = this.props;
|
||||
const { viewport } = this.state;
|
||||
const isDragging =
|
||||
viewport.isDragging === undefined ? false : viewport.isDragging;
|
||||
|
||||
// Compute the clusters based on the original bounds and current zoom level. Note when zoom/pan
|
||||
// to an area outside of the original bounds, no additional queries are made to the backend to
|
||||
// retrieve additional data.
|
||||
// add this variable to widen the visible area
|
||||
const offsetHorizontal = ((width ?? 400) * 0.5) / 100;
|
||||
const offsetVertical = ((height ?? 400) * 0.5) / 100;
|
||||
|
||||
// Guard against empty datasets where bounds may be undefined
|
||||
const bbox =
|
||||
bounds && bounds[0] && bounds[1]
|
||||
? [
|
||||
bounds[0][0] - offsetHorizontal,
|
||||
bounds[0][1] - offsetVertical,
|
||||
bounds[1][0] + offsetHorizontal,
|
||||
bounds[1][1] + offsetVertical,
|
||||
]
|
||||
: [-180, -90, 180, 90]; // Default to world bounds
|
||||
|
||||
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
|
||||
|
||||
return (
|
||||
<MapGL
|
||||
{...viewport}
|
||||
mapStyle={mapStyle}
|
||||
width={width}
|
||||
height={height}
|
||||
mapboxApiAccessToken={mapboxApiKey}
|
||||
onViewportChange={this.handleViewportChange}
|
||||
preserveDrawingBuffer
|
||||
>
|
||||
<ScatterPlotGlowOverlay
|
||||
{...viewport}
|
||||
isDragging={isDragging}
|
||||
locations={clusters}
|
||||
dotRadius={pointRadius}
|
||||
pointRadiusUnit={pointRadiusUnit}
|
||||
rgb={rgb}
|
||||
globalOpacity={globalOpacity}
|
||||
compositeOperation="screen"
|
||||
renderWhileDragging={renderWhileDragging}
|
||||
aggregation={hasCustomMetric ? aggregatorName : undefined}
|
||||
lngLatAccessor={(location: GeoJSONLocation) => {
|
||||
const { coordinates } = location.geometry;
|
||||
|
||||
return [coordinates[0], coordinates[1]];
|
||||
}}
|
||||
/>
|
||||
</MapGL>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MapBox;
|
||||
@@ -1,425 +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.
|
||||
*/
|
||||
/* eslint-disable react/require-default-props */
|
||||
import { PureComponent } from 'react';
|
||||
import { CanvasOverlay } from 'react-map-gl';
|
||||
import { kmToPixels, MILES_PER_KM } from './utils/geo';
|
||||
import roundDecimal from './utils/roundDecimal';
|
||||
import luminanceFromRGB from './utils/luminanceFromRGB';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
// Shared radius bounds keep cluster and point sizing in sync.
|
||||
export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
|
||||
export const MAX_POINT_RADIUS_RATIO = 1 / 3;
|
||||
|
||||
interface GeoJSONLocation {
|
||||
geometry: {
|
||||
coordinates: [number, number];
|
||||
};
|
||||
properties: Record<string, number | string | boolean | null | undefined>;
|
||||
}
|
||||
|
||||
interface RedrawParams {
|
||||
width: number;
|
||||
height: number;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
isDragging: boolean;
|
||||
project: (lngLat: [number, number]) => [number, number];
|
||||
}
|
||||
|
||||
interface DrawTextOptions {
|
||||
fontHeight?: number;
|
||||
label?: string | number;
|
||||
radius?: number;
|
||||
rgb?: (string | number)[];
|
||||
shadow?: boolean;
|
||||
}
|
||||
|
||||
interface ScatterPlotGlowOverlayProps {
|
||||
aggregation?: string;
|
||||
compositeOperation?: string;
|
||||
dotRadius?: number;
|
||||
globalOpacity?: number;
|
||||
lngLatAccessor?: (location: GeoJSONLocation) => [number, number];
|
||||
locations: GeoJSONLocation[];
|
||||
pointRadiusUnit?: string;
|
||||
renderWhileDragging?: boolean;
|
||||
rgb?: (string | number)[];
|
||||
zoom?: number;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
const defaultProps: Partial<ScatterPlotGlowOverlayProps> = {
|
||||
// Same as browser default.
|
||||
compositeOperation: 'source-over',
|
||||
dotRadius: 4,
|
||||
lngLatAccessor: (location: GeoJSONLocation) => [
|
||||
location.geometry.coordinates[0],
|
||||
location.geometry.coordinates[1],
|
||||
],
|
||||
renderWhileDragging: true,
|
||||
};
|
||||
|
||||
const computeClusterLabel = (
|
||||
properties: Record<string, number | string | boolean | null | undefined>,
|
||||
aggregation: string | undefined,
|
||||
): number | string => {
|
||||
const count = properties.point_count as number;
|
||||
if (!aggregation) {
|
||||
return count;
|
||||
}
|
||||
if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
|
||||
return properties[aggregation] as number;
|
||||
}
|
||||
const { sum } = properties as { sum: number };
|
||||
const mean = sum / count;
|
||||
if (aggregation === 'mean') {
|
||||
return Math.round(100 * mean) / 100;
|
||||
}
|
||||
const { squaredSum } = properties as { squaredSum: number };
|
||||
const variance = squaredSum / count - (sum / count) ** 2;
|
||||
if (aggregation === 'var') {
|
||||
return Math.round(100 * variance) / 100;
|
||||
}
|
||||
if (aggregation === 'stdev') {
|
||||
return Math.round(100 * Math.sqrt(variance)) / 100;
|
||||
}
|
||||
|
||||
// fallback to point_count, this really shouldn't happen
|
||||
return count;
|
||||
};
|
||||
|
||||
class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps> {
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
constructor(props: ScatterPlotGlowOverlayProps) {
|
||||
super(props);
|
||||
this.redraw = this.redraw.bind(this);
|
||||
}
|
||||
|
||||
drawText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
pixel: [number, number],
|
||||
options: DrawTextOptions = {},
|
||||
) {
|
||||
const IS_DARK_THRESHOLD = 110;
|
||||
const {
|
||||
fontHeight = 0,
|
||||
label = '',
|
||||
radius = 0,
|
||||
rgb = [0, 0, 0],
|
||||
shadow = false,
|
||||
} = options;
|
||||
const maxWidth = radius * 1.8;
|
||||
const luminance = luminanceFromRGB(
|
||||
rgb[1] as number,
|
||||
rgb[2] as number,
|
||||
rgb[3] as number,
|
||||
);
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
|
||||
ctx.font = `${fontHeight}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
if (shadow) {
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
|
||||
}
|
||||
|
||||
const textWidth = ctx.measureText(String(label)).width;
|
||||
if (textWidth > maxWidth) {
|
||||
const scale = fontHeight / textWidth;
|
||||
ctx.font = `${scale * maxWidth}px sans-serif`;
|
||||
}
|
||||
|
||||
const { compositeOperation } = this.props;
|
||||
|
||||
ctx.fillText(String(label), pixel[0], pixel[1]);
|
||||
ctx.globalCompositeOperation = (compositeOperation ??
|
||||
'source-over') as GlobalCompositeOperation;
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowColor = '';
|
||||
}
|
||||
|
||||
// Modified: https://github.com/uber/react-map-gl/blob/master/overlays/scatterplot.react.js
|
||||
redraw({ width, height, ctx, isDragging, project }: RedrawParams) {
|
||||
const {
|
||||
aggregation,
|
||||
compositeOperation,
|
||||
dotRadius,
|
||||
globalOpacity,
|
||||
lngLatAccessor,
|
||||
locations,
|
||||
pointRadiusUnit,
|
||||
renderWhileDragging,
|
||||
rgb,
|
||||
zoom,
|
||||
} = this.props;
|
||||
|
||||
const radius = dotRadius ?? 4;
|
||||
const clusterLabelMap: (number | string)[] = [];
|
||||
|
||||
locations.forEach((location, i) => {
|
||||
if (location.properties.cluster) {
|
||||
clusterLabelMap[i] = computeClusterLabel(
|
||||
location.properties,
|
||||
aggregation,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const finiteClusterLabels = clusterLabelMap
|
||||
.map(value => Number(value))
|
||||
.filter(value => Number.isFinite(value));
|
||||
const safeMaxAbsLabel =
|
||||
finiteClusterLabels.length > 0
|
||||
? Math.max(
|
||||
Math.max(...finiteClusterLabels.map(value => Math.abs(value))),
|
||||
1,
|
||||
)
|
||||
: 1;
|
||||
|
||||
// Calculate min/max radius values for Pixels mode scaling
|
||||
let minRadiusValue = Infinity;
|
||||
let maxRadiusValue = -Infinity;
|
||||
if (pointRadiusUnit === 'Pixels') {
|
||||
locations.forEach(location => {
|
||||
// Accept both null and undefined as "no value" and coerce potential numeric strings
|
||||
if (
|
||||
!location.properties.cluster &&
|
||||
location.properties.radius != null
|
||||
) {
|
||||
const radiusValueRaw = location.properties.radius;
|
||||
const radiusValue =
|
||||
typeof radiusValueRaw === 'string' && radiusValueRaw.trim() === ''
|
||||
? null
|
||||
: Number(radiusValueRaw);
|
||||
if (radiusValue != null && Number.isFinite(radiusValue)) {
|
||||
minRadiusValue = Math.min(minRadiusValue, radiusValue);
|
||||
maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.globalCompositeOperation = (compositeOperation ??
|
||||
'source-over') as GlobalCompositeOperation;
|
||||
|
||||
if ((renderWhileDragging || !isDragging) && locations) {
|
||||
locations.forEach(function _forEach(
|
||||
this: ScatterPlotGlowOverlay,
|
||||
location: GeoJSONLocation,
|
||||
i: number,
|
||||
) {
|
||||
const pixel = project(lngLatAccessor!(location)) as [number, number];
|
||||
const pixelRounded: [number, number] = [
|
||||
roundDecimal(pixel[0], 1),
|
||||
roundDecimal(pixel[1], 1),
|
||||
];
|
||||
|
||||
if (
|
||||
pixelRounded[0] + radius >= 0 &&
|
||||
pixelRounded[0] - radius < width &&
|
||||
pixelRounded[1] + radius >= 0 &&
|
||||
pixelRounded[1] - radius < height
|
||||
) {
|
||||
ctx.beginPath();
|
||||
if (location.properties.cluster) {
|
||||
const clusterLabel = clusterLabelMap[i];
|
||||
// Validate clusterLabel is a finite number before using it for radius calculation
|
||||
const numericLabel = Number(clusterLabel);
|
||||
const safeNumericLabel = Number.isFinite(numericLabel)
|
||||
? numericLabel
|
||||
: 0;
|
||||
const minClusterRadius =
|
||||
pointRadiusUnit === 'Pixels'
|
||||
? radius * MAX_POINT_RADIUS_RATIO
|
||||
: radius * MIN_CLUSTER_RADIUS_RATIO;
|
||||
const ratio = Math.abs(safeNumericLabel) / safeMaxAbsLabel;
|
||||
const scaledRadius = roundDecimal(
|
||||
minClusterRadius + ratio ** 0.5 * (radius - minClusterRadius),
|
||||
1,
|
||||
);
|
||||
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
|
||||
const [x, y] = pixelRounded;
|
||||
const gradient = ctx.createRadialGradient(
|
||||
x,
|
||||
y,
|
||||
scaledRadius,
|
||||
x,
|
||||
y,
|
||||
0,
|
||||
);
|
||||
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * (globalOpacity ?? 1)})`,
|
||||
);
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
|
||||
);
|
||||
ctx.arc(
|
||||
pixelRounded[0],
|
||||
pixelRounded[1],
|
||||
scaledRadius,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
if (Number.isFinite(safeNumericLabel)) {
|
||||
let label: string | number = clusterLabel;
|
||||
const absLabel = Math.abs(safeNumericLabel);
|
||||
const sign = safeNumericLabel < 0 ? '-' : '';
|
||||
if (absLabel >= 10000) {
|
||||
label = `${sign}${Math.round(absLabel / 1000)}k`;
|
||||
} else if (absLabel >= 1000) {
|
||||
label = `${sign}${Math.round(absLabel / 100) / 10}k`;
|
||||
}
|
||||
this.drawText(ctx, pixelRounded, {
|
||||
fontHeight,
|
||||
label,
|
||||
radius: scaledRadius,
|
||||
rgb,
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const defaultRadius = radius * MIN_CLUSTER_RADIUS_RATIO;
|
||||
const rawRadius = location.properties.radius;
|
||||
const numericRadiusProperty =
|
||||
rawRadius != null &&
|
||||
!(typeof rawRadius === 'string' && rawRadius.trim() === '')
|
||||
? Number(rawRadius)
|
||||
: null;
|
||||
const radiusProperty =
|
||||
numericRadiusProperty != null &&
|
||||
Number.isFinite(numericRadiusProperty)
|
||||
? numericRadiusProperty
|
||||
: null;
|
||||
const pointMetric = location.properties.metric ?? null;
|
||||
let pointRadius: number = radiusProperty ?? defaultRadius;
|
||||
let pointLabel: string | number | undefined;
|
||||
|
||||
if (radiusProperty != null) {
|
||||
const pointLatitude = lngLatAccessor!(location)[1];
|
||||
if (pointRadiusUnit === 'Kilometers') {
|
||||
pointLabel = `${roundDecimal(pointRadius, 2)}km`;
|
||||
pointRadius = kmToPixels(pointRadius, pointLatitude, zoom ?? 0);
|
||||
} else if (pointRadiusUnit === 'Miles') {
|
||||
pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
|
||||
pointRadius = kmToPixels(
|
||||
pointRadius * MILES_PER_KM,
|
||||
pointLatitude,
|
||||
zoom ?? 0,
|
||||
);
|
||||
} else if (pointRadiusUnit === 'Pixels') {
|
||||
// Scale pixel values to a reasonable range (radius/6 to radius/3)
|
||||
// This ensures points are visible and proportional to their values
|
||||
const MIN_POINT_RADIUS = radius * MIN_CLUSTER_RADIUS_RATIO;
|
||||
const MAX_POINT_RADIUS = radius * MAX_POINT_RADIUS_RATIO;
|
||||
|
||||
if (
|
||||
Number.isFinite(minRadiusValue) &&
|
||||
Number.isFinite(maxRadiusValue) &&
|
||||
maxRadiusValue > minRadiusValue
|
||||
) {
|
||||
// Normalize the value to 0-1 range, then scale to pixel range
|
||||
const numericPointRadius = Number(pointRadius);
|
||||
if (!Number.isFinite(numericPointRadius)) {
|
||||
// fallback to minimum visible size when the value is not a finite number
|
||||
pointRadius = MIN_POINT_RADIUS;
|
||||
} else {
|
||||
const normalizedValueRaw =
|
||||
(numericPointRadius - minRadiusValue) /
|
||||
(maxRadiusValue - minRadiusValue);
|
||||
const normalizedValue = Math.max(
|
||||
0,
|
||||
Math.min(1, normalizedValueRaw),
|
||||
);
|
||||
pointRadius =
|
||||
MIN_POINT_RADIUS +
|
||||
normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
|
||||
}
|
||||
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
|
||||
} else if (
|
||||
Number.isFinite(minRadiusValue) &&
|
||||
minRadiusValue === maxRadiusValue
|
||||
) {
|
||||
// All values are the same, use a fixed medium size
|
||||
pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
|
||||
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
|
||||
} else {
|
||||
// Use raw pixel values if they're already in a reasonable range
|
||||
pointRadius = Math.max(
|
||||
MIN_POINT_RADIUS,
|
||||
Math.min(pointRadius, MAX_POINT_RADIUS),
|
||||
);
|
||||
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pointMetric !== null) {
|
||||
const numericMetric = parseFloat(String(pointMetric));
|
||||
pointLabel = Number.isFinite(numericMetric)
|
||||
? roundDecimal(numericMetric, 2)
|
||||
: String(pointMetric);
|
||||
}
|
||||
|
||||
// Fall back to default points if pointRadius wasn't a numerical column
|
||||
if (!pointRadius) {
|
||||
pointRadius = defaultRadius;
|
||||
}
|
||||
|
||||
ctx.arc(
|
||||
pixelRounded[0],
|
||||
pixelRounded[1],
|
||||
roundDecimal(pointRadius, 1),
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
|
||||
ctx.fill();
|
||||
|
||||
if (pointLabel !== undefined) {
|
||||
this.drawText(ctx, pixelRounded, {
|
||||
fontHeight: roundDecimal(pointRadius, 1),
|
||||
label: pointLabel,
|
||||
radius: pointRadius,
|
||||
rgb,
|
||||
shadow: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <CanvasOverlay redraw={this.redraw} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default ScatterPlotGlowOverlay;
|
||||
@@ -1,107 +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.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import MapBoxChartPlugin from '@superset-ui/legacy-plugin-chart-map-box';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import { generateData } from './data';
|
||||
|
||||
new MapBoxChartPlugin().configure({ key: 'map-box' }).register();
|
||||
|
||||
export default {
|
||||
title: 'Legacy Chart Plugins/legacy-plugin-chart-map-box',
|
||||
decorators: [withResizableChartDemo],
|
||||
args: {
|
||||
clusteringRadius: 60,
|
||||
globalOpacity: 1,
|
||||
pointRadius: 'Auto',
|
||||
renderWhileDragging: true,
|
||||
},
|
||||
argTypes: {
|
||||
clusteringRadius: {
|
||||
control: { type: 'range', min: 0, max: 200, step: 10 },
|
||||
description: 'Radius in pixels for clustering points',
|
||||
},
|
||||
globalOpacity: {
|
||||
control: { type: 'range', min: 0, max: 1, step: 0.1 },
|
||||
description: 'Opacity of map markers',
|
||||
},
|
||||
pointRadius: {
|
||||
control: 'select',
|
||||
options: ['Auto', 1, 2, 5, 10, 20, 50],
|
||||
description: 'Size of point markers',
|
||||
},
|
||||
renderWhileDragging: {
|
||||
control: 'boolean',
|
||||
description: 'Render markers while dragging the map',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Note: This chart requires a Mapbox API key to display. Without a valid key, the map background will not render.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MapBoxViz = ({
|
||||
clusteringRadius,
|
||||
globalOpacity,
|
||||
pointRadius,
|
||||
renderWhileDragging,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
clusteringRadius: number;
|
||||
globalOpacity: number;
|
||||
pointRadius: string | number;
|
||||
renderWhileDragging: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<SuperChart
|
||||
chartType="map-box"
|
||||
width={width}
|
||||
height={height}
|
||||
queriesData={[{ data: generateData(theme) }]}
|
||||
formData={{
|
||||
all_columns_x: 'LON',
|
||||
all_columns_y: 'LAT',
|
||||
clustering_radius: String(clusteringRadius),
|
||||
global_opacity: globalOpacity,
|
||||
mapbox_color: 'rgb(244, 176, 42)',
|
||||
mapbox_label: [],
|
||||
mapbox_style: 'mapbox://styles/mapbox/light-v9',
|
||||
pandas_aggfunc: 'sum',
|
||||
point_radius: pointRadius,
|
||||
point_radius_unit: 'Pixels',
|
||||
render_while_dragging: renderWhileDragging,
|
||||
viewport_latitude: 37.78711146014447,
|
||||
viewport_longitude: -122.37633433151713,
|
||||
viewport_zoom: 10.026425338292782,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,162 +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 { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapBox';
|
||||
|
||||
const NOOP = () => {};
|
||||
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 ClusterProperties {
|
||||
metric: number;
|
||||
sum: number;
|
||||
squaredSum: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { width, height, formData, hooks, queriesData } = chartProps;
|
||||
const { onError = NOOP, setControlValue = NOOP } = hooks;
|
||||
const { bounds, geoJSON, hasCustomMetric, mapboxApiKey } =
|
||||
queriesData[0].data;
|
||||
const {
|
||||
clusteringRadius,
|
||||
globalOpacity,
|
||||
mapboxColor,
|
||||
mapboxStyle,
|
||||
pandasAggfunc,
|
||||
pointRadiusUnit,
|
||||
renderWhileDragging,
|
||||
viewportLongitude,
|
||||
viewportLatitude,
|
||||
viewportZoom,
|
||||
} = formData;
|
||||
|
||||
// Validate mapbox color
|
||||
const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(mapboxColor);
|
||||
if (rgb === null) {
|
||||
onError("Color field must be of form 'rgb(%d, %d, %d)'");
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
const opts: SuperclusterOptions<ClusterProperties, ClusterProperties> = {
|
||||
maxZoom: DEFAULT_MAX_ZOOM,
|
||||
radius: clusteringRadius,
|
||||
};
|
||||
if (hasCustomMetric) {
|
||||
opts.initial = () => ({
|
||||
metric: 0,
|
||||
sum: 0,
|
||||
squaredSum: 0,
|
||||
min: Infinity,
|
||||
max: -Infinity,
|
||||
});
|
||||
opts.map = (prop: ClusterProperties) => ({
|
||||
metric: prop.metric,
|
||||
sum: prop.metric,
|
||||
squaredSum: prop.metric ** 2,
|
||||
min: prop.metric,
|
||||
max: prop.metric,
|
||||
});
|
||||
opts.reduce = (accu: ClusterProperties, prop: ClusterProperties) => {
|
||||
// Temporarily disable param-reassignment linting to work with supercluster's api
|
||||
/* 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(opts);
|
||||
clusterer.load(geoJSON.features);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
aggregatorName: pandasAggfunc,
|
||||
bounds,
|
||||
clusterer,
|
||||
hasCustomMetric,
|
||||
mapboxApiKey,
|
||||
mapStyle: mapboxStyle,
|
||||
onViewportChange({
|
||||
latitude,
|
||||
longitude,
|
||||
zoom,
|
||||
}: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom: number;
|
||||
}) {
|
||||
setControlValue('viewport_longitude', longitude);
|
||||
setControlValue('viewport_latitude', latitude);
|
||||
setControlValue('viewport_zoom', zoom);
|
||||
},
|
||||
// Always use DEFAULT_POINT_RADIUS as the base radius for cluster sizing
|
||||
// Individual point radii come from geoJSON properties.radius
|
||||
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,
|
||||
),
|
||||
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
|
||||
};
|
||||
}
|
||||
@@ -1,381 +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 { type ReactNode } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import MapBox from '../src/MapBox';
|
||||
|
||||
// Capture the most recent viewport props passed to MapGL
|
||||
let lastMapGLProps: Record<string, unknown> = {};
|
||||
const mockFitBounds = jest.fn();
|
||||
|
||||
jest.mock('react-map-gl', () => {
|
||||
const MockMapGL = (props: Record<string, unknown>) => {
|
||||
lastMapGLProps = props;
|
||||
return <div data-test="map-gl">{props.children as ReactNode}</div>;
|
||||
};
|
||||
return { __esModule: true, default: MockMapGL };
|
||||
});
|
||||
|
||||
jest.mock('@math.gl/web-mercator', () => ({
|
||||
WebMercatorViewport: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
({ width, height }: { width: number; height: number }) => ({
|
||||
fitBounds: (bounds: [[number, number], [number, number]]) =>
|
||||
mockFitBounds(bounds, width, height),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../src/ScatterPlotGlowOverlay', () => {
|
||||
const MockOverlay = (props: Record<string, unknown>) => (
|
||||
<div data-test="scatter-overlay" data-opacity={props.globalOpacity} />
|
||||
);
|
||||
return { __esModule: true, default: MockOverlay };
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
clusterer: {
|
||||
getClusters: jest.fn().mockReturnValue([]),
|
||||
},
|
||||
globalOpacity: 1,
|
||||
mapboxApiKey: 'test-key',
|
||||
mapStyle: 'mapbox://styles/mapbox/light-v9',
|
||||
pointRadius: 60,
|
||||
pointRadiusUnit: 'Pixels',
|
||||
renderWhileDragging: true,
|
||||
rgb: ['', 255, 0, 0] as (string | number)[],
|
||||
hasCustomMetric: false,
|
||||
bounds: [
|
||||
[-74.0, 40.7],
|
||||
[-73.9, 40.8],
|
||||
] as [[number, number], [number, number]],
|
||||
onViewportChange: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
lastMapGLProps = {};
|
||||
jest.clearAllMocks();
|
||||
mockFitBounds.mockImplementation(
|
||||
(
|
||||
bounds: [[number, number], [number, number]],
|
||||
width: number,
|
||||
height: number,
|
||||
) => ({
|
||||
latitude: Number(((bounds[0][1] + bounds[1][1]) / 2).toFixed(2)),
|
||||
longitude: Number(((bounds[0][0] + bounds[1][0]) / 2).toFixed(2)),
|
||||
zoom: Number((10 + width / 1000 + height / 10000).toFixed(2)),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('initializes viewport from bounds', () => {
|
||||
render(<MapBox {...defaultProps} />);
|
||||
expect(lastMapGLProps.latitude).toBe(40.75);
|
||||
expect(lastMapGLProps.longitude).toBe(-73.95);
|
||||
expect(lastMapGLProps.zoom).toBe(10.86);
|
||||
});
|
||||
|
||||
test('updates viewport when viewport props change', () => {
|
||||
const { rerender } = render(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-73.95}
|
||||
viewportLatitude={40.75}
|
||||
viewportZoom={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapGLProps.longitude).toBe(-122.4);
|
||||
expect(lastMapGLProps.latitude).toBe(37.8);
|
||||
expect(lastMapGLProps.zoom).toBe(5);
|
||||
});
|
||||
|
||||
test('does not loop when viewport state matches new props', () => {
|
||||
const { rerender } = render(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-73.95}
|
||||
viewportLatitude={40.75}
|
||||
viewportZoom={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Re-render with same props that match the initial viewport state
|
||||
rerender(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-73.95}
|
||||
viewportLatitude={40.75}
|
||||
viewportZoom={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Viewport should still be the fitBounds-computed values since props didn't change
|
||||
expect(lastMapGLProps.longitude).toBe(-73.95);
|
||||
expect(lastMapGLProps.latitude).toBe(40.75);
|
||||
expect(lastMapGLProps.zoom).toBe(10);
|
||||
});
|
||||
|
||||
test('passes globalOpacity to ScatterPlotGlowOverlay', () => {
|
||||
const { getByTestId } = render(
|
||||
<MapBox {...defaultProps} globalOpacity={0.5} />,
|
||||
);
|
||||
const overlay = getByTestId('scatter-overlay');
|
||||
expect(overlay.dataset.opacity).toBe('0.5');
|
||||
});
|
||||
|
||||
test('initializes viewport from props when provided', () => {
|
||||
render(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
expect(lastMapGLProps.longitude).toBe(-122.4);
|
||||
expect(lastMapGLProps.latitude).toBe(37.8);
|
||||
expect(lastMapGLProps.zoom).toBe(5);
|
||||
});
|
||||
|
||||
test('handles undefined bounds gracefully', () => {
|
||||
render(<MapBox {...defaultProps} bounds={undefined} />);
|
||||
expect(lastMapGLProps.longitude).toBe(0);
|
||||
expect(lastMapGLProps.latitude).toBe(0);
|
||||
expect(lastMapGLProps.zoom).toBe(1);
|
||||
});
|
||||
|
||||
test('applies partial viewport props on update', () => {
|
||||
const { rerender } = render(<MapBox {...defaultProps} />);
|
||||
|
||||
rerender(<MapBox {...defaultProps} viewportLongitude={-122.4} />);
|
||||
|
||||
expect(lastMapGLProps.longitude).toBe(-122.4);
|
||||
expect(lastMapGLProps.latitude).toBe(40.75);
|
||||
expect(lastMapGLProps.zoom).toBe(10.86);
|
||||
});
|
||||
|
||||
test('restores fitBounds when viewport props are cleared', () => {
|
||||
const { rerender } = render(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear all viewport props (simulates user clearing the controls)
|
||||
rerender(<MapBox {...defaultProps} />);
|
||||
|
||||
// Should revert to fitBounds values
|
||||
expect(lastMapGLProps.longitude).toBe(-73.95);
|
||||
expect(lastMapGLProps.latitude).toBe(40.75);
|
||||
expect(lastMapGLProps.zoom).toBe(10.86);
|
||||
});
|
||||
|
||||
test('restores only cleared viewport props, keeps the rest', () => {
|
||||
const { rerender } = render(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear only longitude, keep lat/zoom
|
||||
rerender(
|
||||
<MapBox {...defaultProps} viewportLatitude={37.8} viewportZoom={5} />,
|
||||
);
|
||||
|
||||
// Longitude reverts to fitBounds, lat/zoom stay
|
||||
expect(lastMapGLProps.longitude).toBe(-73.95);
|
||||
expect(lastMapGLProps.latitude).toBe(37.8);
|
||||
expect(lastMapGLProps.zoom).toBe(5);
|
||||
});
|
||||
|
||||
test('applies changed viewport props even when another is cleared simultaneously', () => {
|
||||
const { rerender } = render(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear longitude, change latitude simultaneously
|
||||
rerender(
|
||||
<MapBox {...defaultProps} viewportLatitude={40.0} viewportZoom={5} />,
|
||||
);
|
||||
|
||||
// Longitude reverts to fitBounds, latitude should be the NEW value
|
||||
expect(lastMapGLProps.longitude).toBe(-73.95);
|
||||
expect(lastMapGLProps.latitude).toBe(40.0);
|
||||
expect(lastMapGLProps.zoom).toBe(5);
|
||||
});
|
||||
|
||||
test('falls back to default viewport when cleared with undefined bounds', () => {
|
||||
const { rerender } = render(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
bounds={undefined}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear viewport props — no bounds to fitBounds to
|
||||
rerender(<MapBox {...defaultProps} bounds={undefined} />);
|
||||
|
||||
// Should fall back to {0, 0, 1}
|
||||
expect(lastMapGLProps.longitude).toBe(0);
|
||||
expect(lastMapGLProps.latitude).toBe(0);
|
||||
expect(lastMapGLProps.zoom).toBe(1);
|
||||
});
|
||||
|
||||
test('recomputes fitBounds when bounds change and no explicit viewport is set', () => {
|
||||
const { rerender } = render(<MapBox {...defaultProps} />);
|
||||
|
||||
rerender(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
bounds={[
|
||||
[-123.2, 36.5],
|
||||
[-121.8, 38.1],
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapGLProps.longitude).toBe(-122.5);
|
||||
expect(lastMapGLProps.latitude).toBe(37.3);
|
||||
expect(lastMapGLProps.zoom).toBe(10.86);
|
||||
});
|
||||
|
||||
test('recomputes fitBounds when chart size changes and no explicit viewport is set', () => {
|
||||
const { rerender } = render(<MapBox {...defaultProps} />);
|
||||
|
||||
rerender(<MapBox {...defaultProps} width={1200} height={900} />);
|
||||
|
||||
expect(lastMapGLProps.longitude).toBe(-73.95);
|
||||
expect(lastMapGLProps.latitude).toBe(40.75);
|
||||
expect(lastMapGLProps.zoom).toBe(11.29);
|
||||
});
|
||||
|
||||
test('recomputes only implicit viewport fields when bounds change', () => {
|
||||
const { rerender } = render(
|
||||
<MapBox {...defaultProps} viewportLongitude={-122.4} />,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
bounds={[
|
||||
[-123.2, 36.5],
|
||||
[-121.8, 38.1],
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapGLProps.longitude).toBe(-122.4);
|
||||
expect(lastMapGLProps.latitude).toBe(37.3);
|
||||
expect(lastMapGLProps.zoom).toBe(10.86);
|
||||
});
|
||||
|
||||
test('recomputes only implicit viewport fields when chart size changes', () => {
|
||||
const { rerender } = render(
|
||||
<MapBox {...defaultProps} viewportLatitude={37.8} />,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLatitude={37.8}
|
||||
width={1200}
|
||||
height={900}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapGLProps.longitude).toBe(-73.95);
|
||||
expect(lastMapGLProps.latitude).toBe(37.8);
|
||||
expect(lastMapGLProps.zoom).toBe(11.29);
|
||||
});
|
||||
|
||||
test('recomputes implicit position when zoom stays explicit across bounds changes', () => {
|
||||
const { rerender } = render(<MapBox {...defaultProps} viewportZoom={5} />);
|
||||
|
||||
rerender(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportZoom={5}
|
||||
bounds={[
|
||||
[-123.2, 36.5],
|
||||
[-121.8, 38.1],
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapGLProps.longitude).toBe(-122.5);
|
||||
expect(lastMapGLProps.latitude).toBe(37.3);
|
||||
expect(lastMapGLProps.zoom).toBe(5);
|
||||
});
|
||||
|
||||
test('does not recompute fitBounds on bounds change when an explicit viewport is set', () => {
|
||||
const { rerender } = render(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<MapBox
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
bounds={[
|
||||
[-123.2, 36.5],
|
||||
[-121.8, 38.1],
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapGLProps.longitude).toBe(-122.4);
|
||||
expect(lastMapGLProps.latitude).toBe(37.8);
|
||||
expect(lastMapGLProps.zoom).toBe(5);
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"emitDeclarationOnly": false,
|
||||
"rootDir": "."
|
||||
},
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Path Resolution: Override baseUrl to maintain correct path mappings from parent config
|
||||
// (e.g., "@apache-superset/core" -> "./packages/superset-core/src")
|
||||
"baseUrl": "../..",
|
||||
|
||||
// Directory Overrides: Parent config paths are relative to frontend root,
|
||||
// but packages need paths relative to their own directory
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"declarationDir": "lib"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||
"exclude": [
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
"src/**/*.test.*",
|
||||
"src/**/*.stories.*"],
|
||||
"references": [
|
||||
{ "path": "../../packages/superset-core" },
|
||||
{ "path": "../../packages/superset-ui-core" },
|
||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||
]
|
||||
}
|
||||
@@ -1,101 +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.
|
||||
*/
|
||||
|
||||
declare module '*.png' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module 'supercluster' {
|
||||
interface Options<P = Record<string, unknown>, C = Record<string, unknown>> {
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
minPoints?: number;
|
||||
radius?: number;
|
||||
extent?: number;
|
||||
nodeSize?: number;
|
||||
log?: boolean;
|
||||
initial?: () => C;
|
||||
map?: (props: P) => C;
|
||||
reduce?: (accumulated: C, props: C) => void;
|
||||
}
|
||||
|
||||
interface GeoJSONFeature {
|
||||
type: string;
|
||||
geometry: {
|
||||
type: string;
|
||||
coordinates: [number, number];
|
||||
};
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class Supercluster<P = Record<string, unknown>, C = Record<string, unknown>> {
|
||||
constructor(options?: Options<P, C>);
|
||||
load(points: GeoJSONFeature[]): Supercluster<P, C>;
|
||||
getClusters(bbox: number[], zoom: number): GeoJSONFeature[];
|
||||
getTile(z: number, x: number, y: number): GeoJSONFeature[] | null;
|
||||
getChildren(clusterId: number): GeoJSONFeature[];
|
||||
getLeaves(
|
||||
clusterId: number,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): GeoJSONFeature[];
|
||||
getClusterExpansionZoom(clusterId: number): number;
|
||||
}
|
||||
|
||||
export default Supercluster;
|
||||
export { Options, GeoJSONFeature };
|
||||
}
|
||||
|
||||
declare module 'react-map-gl' {
|
||||
import { Component, ReactNode } from 'react';
|
||||
|
||||
interface MapGLProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
zoom?: number;
|
||||
mapStyle?: string;
|
||||
mapboxApiAccessToken?: string;
|
||||
onViewportChange?: Function;
|
||||
preserveDrawingBuffer?: boolean;
|
||||
children?: ReactNode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default class MapGL extends Component<MapGLProps> {}
|
||||
|
||||
interface CanvasOverlayProps {
|
||||
redraw: (params: {
|
||||
width: number;
|
||||
height: number;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
isDragging: boolean;
|
||||
project: (lngLat: [number, number]) => [number, number];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export class CanvasOverlay extends Component<CanvasOverlayProps> {}
|
||||
}
|
||||
@@ -1,89 +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.
|
||||
-->
|
||||
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [0.20.4](https://github.com/apache/superset/compare/v0.20.3...v0.20.4) (2024-12-10)
|
||||
|
||||
**Note:** Version bump only for package @superset-ui/legacy-preset-chart-deckgl
|
||||
|
||||
# [0.20.0](https://github.com/apache/superset/compare/v2021.41.0...v0.20.0) (2024-09-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Dashboard:** Color inconsistency on refreshes and conflicts ([#27439](https://github.com/apache/superset/issues/27439)) ([313ee59](https://github.com/apache/superset/commit/313ee596f5435894f857d72be7269d5070c8c964))
|
||||
- deck.gl Geojson path not visible ([#24428](https://github.com/apache/superset/issues/24428)) ([6bb930e](https://github.com/apache/superset/commit/6bb930ef4ed26ea381e7f8e889851aa7867ba0eb))
|
||||
- deck.gl GeoJsonLayer Autozoom & fill/stroke options ([#19778](https://github.com/apache/superset/issues/19778)) ([d65b77e](https://github.com/apache/superset/commit/d65b77ec7dac4c2368fcaa1fe6e98db102966198))
|
||||
- **deck.gl Multiple Layer Chart:** Add Contour and Heatmap Layer as options ([#25923](https://github.com/apache/superset/issues/25923)) ([64ba579](https://github.com/apache/superset/commit/64ba5797df92d0f8067ccd2b30ba6ff58e0bd791))
|
||||
- deck.gl Scatterplot min/max radius ([#24363](https://github.com/apache/superset/issues/24363)) ([c728cdf](https://github.com/apache/superset/commit/c728cdf501ec292beb14a0982265052bf2274bec))
|
||||
- **deck.gl:** multiple layers map size is shrunk ([#18939](https://github.com/apache/superset/issues/18939)) ([2cb3635](https://github.com/apache/superset/commit/2cb3635256ee8e91f0bac2f3091684673c04ff2b))
|
||||
- **deck.gl:** update view state on property changes ([#17720](https://github.com/apache/superset/issues/17720)) ([#17826](https://github.com/apache/superset/issues/17826)) ([97d918b](https://github.com/apache/superset/commit/97d918b6927f572dca3b33c61b89c8b3ebdc4376))
|
||||
- DeckGL legend layout ([#30140](https://github.com/apache/superset/issues/30140)) ([af066a4](https://github.com/apache/superset/commit/af066a46306f2f476aa2944b14df3de1faf1e96d))
|
||||
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
|
||||
- **explore:** Fix chart standalone URL for report/thumbnail generation ([#20673](https://github.com/apache/superset/issues/20673)) ([84d4302](https://github.com/apache/superset/commit/84d4302628d18aa19c13cc5322e68abbc690ea4d))
|
||||
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
|
||||
- weight tooltip issue ([#19397](https://github.com/apache/superset/issues/19397)) ([f6d550b](https://github.com/apache/superset/commit/f6d550b7fc3643350483850064e65dbd3d026dc4))
|
||||
|
||||
### Features
|
||||
|
||||
- Add Deck.gl Contour Layer ([#24154](https://github.com/apache/superset/issues/24154)) ([512fb9a](https://github.com/apache/superset/commit/512fb9a0bdd428b94b0c121158b8b15b7631e0fb))
|
||||
- Add deck.gl Heatmap Visualization ([#23551](https://github.com/apache/superset/issues/23551)) ([fc8c537](https://github.com/apache/superset/commit/fc8c537118ce6c7b3a4624f88a31e2e7fb287327))
|
||||
- Add line width unit control in deckgl Polygon and Path ([#24755](https://github.com/apache/superset/issues/24755)) ([d26ea98](https://github.com/apache/superset/commit/d26ea980acc7d2a20757efc360d810afe83d5c65))
|
||||
- apply standardized form data to deckgl ([#20579](https://github.com/apache/superset/issues/20579)) ([290b89c](https://github.com/apache/superset/commit/290b89c7b4ae702c55f611bfac9cedb245ea8bd8))
|
||||
- **deck.gl:** add color range for deck.gl 3D ([#19520](https://github.com/apache/superset/issues/19520)) ([c0a00fd](https://github.com/apache/superset/commit/c0a00fd302ec66fbe0ca766cf73978c99ba00d82))
|
||||
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
|
||||
- **explore:** Frontend implementation of dataset creation from infobox ([#19855](https://github.com/apache/superset/issues/19855)) ([ba0c37d](https://github.com/apache/superset/commit/ba0c37d3df85b1af39404af1d578daeb0ff2d278))
|
||||
- improve color consistency (save all labels) ([#19038](https://github.com/apache/superset/issues/19038)) ([dc57508](https://github.com/apache/superset/commit/dc575080d7e43d40b1734bb8f44fdc291cb95b11))
|
||||
- **legacy-preset-chart-deckgl:** Add ,.1f and ,.2f value formats to deckgl charts ([#18945](https://github.com/apache/superset/issues/18945)) ([c56dc8e](https://github.com/apache/superset/commit/c56dc8eace6a71b45240d1bb6768d75661052a2e))
|
||||
- make data tables support html ([#24368](https://github.com/apache/superset/issues/24368)) ([d2b0b8e](https://github.com/apache/superset/commit/d2b0b8eac52ad8b68639c6581a1ed174a593f564))
|
||||
- **viz picker:** Remove some tags, refactor Recommended section ([#27708](https://github.com/apache/superset/issues/27708)) ([c314999](https://github.com/apache/superset/commit/c3149994ac0d4392e0462421b62cd0c034142082))
|
||||
|
||||
# [0.19.0](https://github.com/apache/superset/compare/v2021.41.0...v0.19.0) (2024-09-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Dashboard:** Color inconsistency on refreshes and conflicts ([#27439](https://github.com/apache/superset/issues/27439)) ([313ee59](https://github.com/apache/superset/commit/313ee596f5435894f857d72be7269d5070c8c964))
|
||||
- deck.gl Geojson path not visible ([#24428](https://github.com/apache/superset/issues/24428)) ([6bb930e](https://github.com/apache/superset/commit/6bb930ef4ed26ea381e7f8e889851aa7867ba0eb))
|
||||
- deck.gl GeoJsonLayer Autozoom & fill/stroke options ([#19778](https://github.com/apache/superset/issues/19778)) ([d65b77e](https://github.com/apache/superset/commit/d65b77ec7dac4c2368fcaa1fe6e98db102966198))
|
||||
- **deck.gl Multiple Layer Chart:** Add Contour and Heatmap Layer as options ([#25923](https://github.com/apache/superset/issues/25923)) ([64ba579](https://github.com/apache/superset/commit/64ba5797df92d0f8067ccd2b30ba6ff58e0bd791))
|
||||
- deck.gl Scatterplot min/max radius ([#24363](https://github.com/apache/superset/issues/24363)) ([c728cdf](https://github.com/apache/superset/commit/c728cdf501ec292beb14a0982265052bf2274bec))
|
||||
- **deck.gl:** multiple layers map size is shrunk ([#18939](https://github.com/apache/superset/issues/18939)) ([2cb3635](https://github.com/apache/superset/commit/2cb3635256ee8e91f0bac2f3091684673c04ff2b))
|
||||
- **deck.gl:** update view state on property changes ([#17720](https://github.com/apache/superset/issues/17720)) ([#17826](https://github.com/apache/superset/issues/17826)) ([97d918b](https://github.com/apache/superset/commit/97d918b6927f572dca3b33c61b89c8b3ebdc4376))
|
||||
- DeckGL legend layout ([#30140](https://github.com/apache/superset/issues/30140)) ([af066a4](https://github.com/apache/superset/commit/af066a46306f2f476aa2944b14df3de1faf1e96d))
|
||||
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
|
||||
- **explore:** Fix chart standalone URL for report/thumbnail generation ([#20673](https://github.com/apache/superset/issues/20673)) ([84d4302](https://github.com/apache/superset/commit/84d4302628d18aa19c13cc5322e68abbc690ea4d))
|
||||
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
|
||||
- weight tooltip issue ([#19397](https://github.com/apache/superset/issues/19397)) ([f6d550b](https://github.com/apache/superset/commit/f6d550b7fc3643350483850064e65dbd3d026dc4))
|
||||
|
||||
### Features
|
||||
|
||||
- Add Deck.gl Contour Layer ([#24154](https://github.com/apache/superset/issues/24154)) ([512fb9a](https://github.com/apache/superset/commit/512fb9a0bdd428b94b0c121158b8b15b7631e0fb))
|
||||
- Add deck.gl Heatmap Visualization ([#23551](https://github.com/apache/superset/issues/23551)) ([fc8c537](https://github.com/apache/superset/commit/fc8c537118ce6c7b3a4624f88a31e2e7fb287327))
|
||||
- Add line width unit control in deckgl Polygon and Path ([#24755](https://github.com/apache/superset/issues/24755)) ([d26ea98](https://github.com/apache/superset/commit/d26ea980acc7d2a20757efc360d810afe83d5c65))
|
||||
- apply standardized form data to deckgl ([#20579](https://github.com/apache/superset/issues/20579)) ([290b89c](https://github.com/apache/superset/commit/290b89c7b4ae702c55f611bfac9cedb245ea8bd8))
|
||||
- **deck.gl:** add color range for deck.gl 3D ([#19520](https://github.com/apache/superset/issues/19520)) ([c0a00fd](https://github.com/apache/superset/commit/c0a00fd302ec66fbe0ca766cf73978c99ba00d82))
|
||||
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
|
||||
- **explore:** Frontend implementation of dataset creation from infobox ([#19855](https://github.com/apache/superset/issues/19855)) ([ba0c37d](https://github.com/apache/superset/commit/ba0c37d3df85b1af39404af1d578daeb0ff2d278))
|
||||
- improve color consistency (save all labels) ([#19038](https://github.com/apache/superset/issues/19038)) ([dc57508](https://github.com/apache/superset/commit/dc575080d7e43d40b1734bb8f44fdc291cb95b11))
|
||||
- **legacy-preset-chart-deckgl:** Add ,.1f and ,.2f value formats to deckgl charts ([#18945](https://github.com/apache/superset/issues/18945)) ([c56dc8e](https://github.com/apache/superset/commit/c56dc8eace6a71b45240d1bb6768d75661052a2e))
|
||||
- make data tables support html ([#24368](https://github.com/apache/superset/issues/24368)) ([d2b0b8e](https://github.com/apache/superset/commit/d2b0b8eac52ad8b68639c6581a1ed174a593f564))
|
||||
- **viz picker:** Remove some tags, refactor Recommended section ([#27708](https://github.com/apache/superset/issues/27708)) ([c314999](https://github.com/apache/superset/commit/c3149994ac0d4392e0462421b62cd0c034142082))
|
||||
@@ -1,57 +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.
|
||||
-->
|
||||
|
||||
## @superset-ui/legacy-preset-chart-deckgl
|
||||
|
||||
[](https://img.shields.io/npm/v/@superset-ui/legacy-preset-chart-deckgl.svg?style=flat-square)
|
||||
[](https://libraries.io/npm/@superset-ui%2Flegacy-preset-chart-deckgl)
|
||||
|
||||
This plugin provides `deck.gl` for Superset.
|
||||
|
||||
### Usage
|
||||
|
||||
Import the preset and register. This will register all the chart plugins under `deck.gl`.
|
||||
|
||||
```js
|
||||
import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
|
||||
new DeckGLChartPreset().register();
|
||||
```
|
||||
|
||||
or register charts one by one. Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app.
|
||||
|
||||
```js
|
||||
import { ArcChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
|
||||
new ArcChartPlugin().configure({ key: 'deck_arc' }).register();
|
||||
```
|
||||
|
||||
Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui-plugins-deckgl) for more details.
|
||||
|
||||
```js
|
||||
<SuperChart
|
||||
chartType="deck_arc"
|
||||
width={600}
|
||||
height={600}
|
||||
formData={...}
|
||||
queriesData={[{
|
||||
data: {...},
|
||||
}]}
|
||||
/>
|
||||
```
|
||||
@@ -1,71 +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.
|
||||
*/
|
||||
|
||||
declare module 'deck.gl' {
|
||||
import { Layer, LayerProps } from '@deck.gl/core';
|
||||
|
||||
interface HeatmapLayerProps<T extends object = any> extends LayerProps<T> {
|
||||
id?: string;
|
||||
data?: T[];
|
||||
getPosition?: (d: T) => number[] | null | undefined;
|
||||
getWeight?: (d: T) => number | null | undefined;
|
||||
radiusPixels?: number;
|
||||
colorRange?: number[][];
|
||||
threshold?: number;
|
||||
intensity?: number;
|
||||
aggregation?: string;
|
||||
}
|
||||
|
||||
interface ContourLayerProps<T extends object = any> extends LayerProps<T> {
|
||||
id?: string;
|
||||
data?: T[];
|
||||
getPosition?: (d: T) => number[] | null | undefined;
|
||||
getWeight?: (d: T) => number | null | undefined;
|
||||
contours: {
|
||||
color?: ColorType | undefined;
|
||||
lowerThreshold?: any | undefined;
|
||||
upperThreshold?: any | undefined;
|
||||
strokeWidth?: any | undefined;
|
||||
zIndex?: any | undefined;
|
||||
};
|
||||
cellSize: number;
|
||||
colorRange?: number[][];
|
||||
intensity?: number;
|
||||
aggregation?: string;
|
||||
}
|
||||
|
||||
export class HeatmapLayer<T extends object = any> extends Layer<
|
||||
T,
|
||||
HeatmapLayerProps<T>
|
||||
> {
|
||||
constructor(props: HeatmapLayerProps<T>);
|
||||
}
|
||||
|
||||
export class ContourLayer<T extends object = any> extends Layer<
|
||||
T,
|
||||
ContourLayerProps<T>
|
||||
> {
|
||||
constructor(props: ContourLayerProps<T>);
|
||||
}
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@superset-ui/legacy-plugin-chart-map-box",
|
||||
"version": "0.20.3",
|
||||
"description": "Superset Legacy Chart - MapBox",
|
||||
"name": "@superset-ui/plugin-chart-point-cluster-map",
|
||||
"version": "1.0.0",
|
||||
"description": "Superset Chart Plugin - Point Cluster Map",
|
||||
"keywords": [
|
||||
"superset"
|
||||
],
|
||||
@@ -12,7 +12,7 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/apache/superset.git",
|
||||
"directory": "superset-frontend/plugins/legacy-plugin-chart-map-box"
|
||||
"directory": "superset-frontend/plugins/plugin-chart-point-cluster-map"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "Superset",
|
||||
@@ -27,16 +27,17 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-map-gl": "^6.1.19",
|
||||
"mapbox-gl": "^3.0.0",
|
||||
"maplibre-gl": "^5.0.0",
|
||||
"react-map-gl": "^8.0.0",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"mapbox-gl": "*",
|
||||
"react": "^17.0.2"
|
||||
"react": "^17.0.2 || ^19.0.0",
|
||||
"react-dom": "^17.0.2 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -16,6 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
.mapbox .slice_container div {
|
||||
.maplibre .slice_container div {
|
||||
padding-top: 0px;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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 { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
|
||||
import { Map as MapboxMap } from 'react-map-gl/mapbox';
|
||||
import { WebMercatorViewport } from '@math.gl/web-mercator';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import ScatterPlotOverlay from './components/ScatterPlotOverlay';
|
||||
import { getMapboxApiKey } from './utils/mapbox';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import './MapLibre.css';
|
||||
|
||||
const DEFAULT_MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
|
||||
|
||||
export const DEFAULT_MAX_ZOOM = 16;
|
||||
export const DEFAULT_POINT_RADIUS = 60;
|
||||
|
||||
interface Viewport {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
interface Clusterer {
|
||||
getClusters(bbox: number[], zoom: number): GeoJSONLocation[];
|
||||
}
|
||||
|
||||
interface GeoJSONLocation {
|
||||
geometry: {
|
||||
coordinates: [number, number];
|
||||
};
|
||||
properties: Record<string, number | string | boolean | null | undefined>;
|
||||
}
|
||||
|
||||
interface MapLibreProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
aggregatorName?: string;
|
||||
clusterer: Clusterer; // Required - used for getClusters()
|
||||
globalOpacity?: number;
|
||||
hasCustomMetric?: boolean;
|
||||
mapProvider?: string;
|
||||
mapStyle?: string;
|
||||
onViewportChange?: (viewport: Viewport) => void;
|
||||
pointRadius?: number;
|
||||
pointRadiusUnit?: string;
|
||||
renderWhileDragging?: boolean;
|
||||
rgb?: (string | number)[];
|
||||
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
|
||||
viewportLongitude?: number;
|
||||
viewportLatitude?: number;
|
||||
viewportZoom?: number;
|
||||
}
|
||||
|
||||
function MapLibre({
|
||||
width = 400,
|
||||
height = 400,
|
||||
aggregatorName,
|
||||
clusterer,
|
||||
globalOpacity = 1,
|
||||
hasCustomMetric,
|
||||
mapProvider,
|
||||
mapStyle,
|
||||
onViewportChange,
|
||||
pointRadius = DEFAULT_POINT_RADIUS,
|
||||
pointRadiusUnit = 'Pixels',
|
||||
renderWhileDragging = true,
|
||||
rgb,
|
||||
bounds,
|
||||
viewportLongitude,
|
||||
viewportLatitude,
|
||||
viewportZoom,
|
||||
}: MapLibreProps) {
|
||||
const computeFitBounds = useCallback((): Viewport => {
|
||||
if (bounds && bounds[0] && bounds[1]) {
|
||||
const mercator = new WebMercatorViewport({ width, height }).fitBounds(
|
||||
bounds,
|
||||
);
|
||||
return {
|
||||
latitude: mercator.latitude,
|
||||
longitude: mercator.longitude,
|
||||
zoom: mercator.zoom,
|
||||
};
|
||||
}
|
||||
return { latitude: 0, longitude: 0, zoom: 1 };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const mergeViewportWithProps = useCallback(
|
||||
(fitBounds: Viewport, base: Viewport = fitBounds): Viewport => ({
|
||||
...base,
|
||||
longitude: viewportLongitude ?? fitBounds.longitude,
|
||||
latitude: viewportLatitude ?? fitBounds.latitude,
|
||||
zoom: viewportZoom ?? fitBounds.zoom,
|
||||
}),
|
||||
[viewportLongitude, viewportLatitude, viewportZoom],
|
||||
);
|
||||
|
||||
const [viewport, setViewport] = useState<Viewport>(() =>
|
||||
mergeViewportWithProps(computeFitBounds()),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fitBounds = computeFitBounds();
|
||||
const next = mergeViewportWithProps(fitBounds, viewport);
|
||||
if (
|
||||
next.longitude !== viewport.longitude ||
|
||||
next.latitude !== viewport.latitude ||
|
||||
next.zoom !== viewport.zoom
|
||||
) {
|
||||
setViewport(next);
|
||||
}
|
||||
// Only re-run when the viewport-override props change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewportLongitude, viewportLatitude, viewportZoom]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(evt: {
|
||||
viewState: { longitude: number; latitude: number; zoom: number };
|
||||
}) => {
|
||||
const { longitude, latitude, zoom } = evt.viewState;
|
||||
const newViewport = { longitude, latitude, zoom };
|
||||
setViewport(newViewport);
|
||||
onViewportChange?.(newViewport);
|
||||
},
|
||||
[onViewportChange],
|
||||
);
|
||||
|
||||
// add this variable to widen the visible area
|
||||
const offsetHorizontal = (width * 0.5) / 100;
|
||||
const offsetVertical = (height * 0.5) / 100;
|
||||
|
||||
const bbox =
|
||||
bounds && bounds[0] && bounds[1]
|
||||
? [
|
||||
bounds[0][0] - offsetHorizontal,
|
||||
bounds[0][1] - offsetVertical,
|
||||
bounds[1][0] + offsetHorizontal,
|
||||
bounds[1][1] + offsetVertical,
|
||||
]
|
||||
: [-180, -90, 180, 90];
|
||||
|
||||
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
|
||||
|
||||
const theme = useTheme();
|
||||
const resolvedMapStyle = mapStyle || DEFAULT_MAP_STYLE;
|
||||
const mapboxApiKey = mapProvider === 'mapbox' ? getMapboxApiKey() : '';
|
||||
|
||||
if (mapProvider === 'mapbox' && !mapboxApiKey) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
textAlign: 'center',
|
||||
color: theme.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
{t('Mapbox requires a MAPBOX_API_KEY to be configured on the server.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MapComponent = mapProvider === 'mapbox' ? MapboxMap : MapLibreMap;
|
||||
const mapboxProps =
|
||||
mapProvider === 'mapbox' ? { mapboxAccessToken: mapboxApiKey } : {};
|
||||
|
||||
return (
|
||||
<MapComponent
|
||||
{...viewport}
|
||||
{...mapboxProps}
|
||||
style={{ width, height }}
|
||||
mapStyle={resolvedMapStyle}
|
||||
onMove={handleMove}
|
||||
>
|
||||
<ScatterPlotOverlay
|
||||
locations={clusters}
|
||||
dotRadius={pointRadius}
|
||||
pointRadiusUnit={pointRadiusUnit}
|
||||
rgb={rgb}
|
||||
globalOpacity={globalOpacity}
|
||||
compositeOperation="screen"
|
||||
renderWhileDragging={renderWhileDragging}
|
||||
aggregation={hasCustomMetric ? aggregatorName : undefined}
|
||||
zoom={viewport.zoom}
|
||||
lngLatAccessor={(location: GeoJSONLocation) => {
|
||||
const { coordinates } = location.geometry;
|
||||
return [coordinates[0], coordinates[1]];
|
||||
}}
|
||||
/>
|
||||
</MapComponent>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MapLibre);
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useMap as useMapLibre } from 'react-map-gl/maplibre';
|
||||
import { useMap as useMapbox } from 'react-map-gl/mapbox';
|
||||
|
||||
export interface RedrawParams {
|
||||
width: number;
|
||||
height: number;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
isDragging: boolean;
|
||||
project: (lngLat: [number, number]) => [number, number];
|
||||
}
|
||||
|
||||
interface CanvasOverlayProps {
|
||||
redraw: (params: RedrawParams) => void;
|
||||
}
|
||||
|
||||
export default function CanvasOverlay({ redraw }: CanvasOverlayProps) {
|
||||
const mapLibreContext = useMapLibre();
|
||||
const mapboxContext = useMapbox();
|
||||
const mapRef = (mapLibreContext.current ?? mapboxContext.current) as any;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const project = useCallback(
|
||||
(lngLat: [number, number]): [number, number] => {
|
||||
if (!mapRef) return [0, 0];
|
||||
const map = mapRef.getMap();
|
||||
const point = map.project(lngLat);
|
||||
return [point.x, point.y];
|
||||
},
|
||||
[mapRef],
|
||||
);
|
||||
|
||||
const performRedraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const map = mapRef?.getMap();
|
||||
if (!canvas || !map) return;
|
||||
|
||||
const container = map.getContainer();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
redraw({
|
||||
width,
|
||||
height,
|
||||
ctx,
|
||||
isDragging: isDraggingRef.current,
|
||||
project,
|
||||
});
|
||||
}, [mapRef, redraw, project]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef?.getMap();
|
||||
if (!map) return undefined;
|
||||
|
||||
const onMove = () => performRedraw();
|
||||
const onDragStart = () => {
|
||||
isDraggingRef.current = true;
|
||||
};
|
||||
const onDragEnd = () => {
|
||||
isDraggingRef.current = false;
|
||||
performRedraw();
|
||||
};
|
||||
const onResize = () => performRedraw();
|
||||
|
||||
map.on('move', onMove);
|
||||
map.on('dragstart', onDragStart);
|
||||
map.on('dragend', onDragEnd);
|
||||
map.on('resize', onResize);
|
||||
|
||||
performRedraw();
|
||||
|
||||
return () => {
|
||||
map.off('move', onMove);
|
||||
map.off('dragstart', onDragStart);
|
||||
map.off('dragend', onDragEnd);
|
||||
map.off('resize', onResize);
|
||||
};
|
||||
}, [mapRef, performRedraw]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 { memo, useCallback } from 'react';
|
||||
import CanvasOverlay, { type RedrawParams } from './CanvasOverlay';
|
||||
import { kmToPixels, MILES_PER_KM } from '../utils/geo';
|
||||
import roundDecimal from '../utils/roundDecimal';
|
||||
import luminanceFromRGB from '../utils/luminanceFromRGB';
|
||||
|
||||
// Shared radius bounds keep cluster and point sizing in sync.
|
||||
export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
|
||||
export const MAX_POINT_RADIUS_RATIO = 1 / 3;
|
||||
|
||||
interface GeoJSONLocation {
|
||||
geometry: {
|
||||
coordinates: [number, number];
|
||||
};
|
||||
properties: Record<string, number | string | boolean | null | undefined>;
|
||||
}
|
||||
|
||||
interface DrawTextOptions {
|
||||
fontHeight?: number;
|
||||
label?: string | number;
|
||||
radius?: number;
|
||||
rgb?: (string | number)[];
|
||||
shadow?: boolean;
|
||||
}
|
||||
|
||||
interface ScatterPlotOverlayProps {
|
||||
aggregation?: string;
|
||||
compositeOperation?: string;
|
||||
dotRadius?: number;
|
||||
globalOpacity?: number;
|
||||
lngLatAccessor?: (location: GeoJSONLocation) => [number, number];
|
||||
locations: GeoJSONLocation[];
|
||||
pointRadiusUnit?: string;
|
||||
renderWhileDragging?: boolean;
|
||||
rgb?: (string | number)[];
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
const IS_DARK_THRESHOLD = 110;
|
||||
|
||||
const defaultLngLatAccessor = (location: GeoJSONLocation): [number, number] => [
|
||||
location.geometry.coordinates[0],
|
||||
location.geometry.coordinates[1],
|
||||
];
|
||||
|
||||
const computeClusterLabel = (
|
||||
properties: Record<string, number | string | boolean | null | undefined>,
|
||||
aggregation: string | undefined,
|
||||
): number | string => {
|
||||
const count = properties.point_count as number;
|
||||
if (!aggregation) {
|
||||
return count;
|
||||
}
|
||||
if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
|
||||
return properties[aggregation] as number;
|
||||
}
|
||||
const { sum } = properties as { sum: number };
|
||||
const mean = sum / count;
|
||||
if (aggregation === 'mean') {
|
||||
return Math.round(100 * mean) / 100;
|
||||
}
|
||||
const { squaredSum } = properties as { squaredSum: number };
|
||||
const variance = squaredSum / count - (sum / count) ** 2;
|
||||
if (aggregation === 'var') {
|
||||
return Math.round(100 * variance) / 100;
|
||||
}
|
||||
if (aggregation === 'std' || aggregation === 'stdev') {
|
||||
return Math.round(100 * Math.sqrt(variance)) / 100;
|
||||
}
|
||||
|
||||
// fallback to point_count
|
||||
return count;
|
||||
};
|
||||
|
||||
function drawText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
pixel: [number, number],
|
||||
compositeOperation: string,
|
||||
options: DrawTextOptions = {},
|
||||
) {
|
||||
const {
|
||||
fontHeight = 0,
|
||||
label = '',
|
||||
radius = 0,
|
||||
rgb = [0, 0, 0],
|
||||
shadow = false,
|
||||
} = options;
|
||||
const maxWidth = radius * 1.8;
|
||||
const luminance = luminanceFromRGB(
|
||||
rgb[1] as number,
|
||||
rgb[2] as number,
|
||||
rgb[3] as number,
|
||||
);
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
|
||||
ctx.font = `${fontHeight}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
if (shadow) {
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
|
||||
}
|
||||
|
||||
const textWidth = ctx.measureText(String(label)).width;
|
||||
if (textWidth > maxWidth) {
|
||||
const scale = fontHeight / textWidth;
|
||||
ctx.font = `${scale * maxWidth}px sans-serif`;
|
||||
}
|
||||
|
||||
ctx.fillText(String(label), pixel[0], pixel[1]);
|
||||
ctx.globalCompositeOperation = compositeOperation as GlobalCompositeOperation;
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowColor = '';
|
||||
}
|
||||
|
||||
function ScatterPlotOverlay({
|
||||
aggregation,
|
||||
compositeOperation = 'source-over',
|
||||
dotRadius = 4,
|
||||
globalOpacity = 1,
|
||||
lngLatAccessor = defaultLngLatAccessor,
|
||||
locations,
|
||||
pointRadiusUnit,
|
||||
renderWhileDragging = true,
|
||||
rgb,
|
||||
zoom,
|
||||
}: ScatterPlotOverlayProps) {
|
||||
const redraw = useCallback(
|
||||
({ width, height, ctx, isDragging, project }: RedrawParams) => {
|
||||
const radius = dotRadius;
|
||||
const clusterLabelMap: (number | string)[] = [];
|
||||
|
||||
locations.forEach((location, i) => {
|
||||
if (location.properties.cluster) {
|
||||
clusterLabelMap[i] = computeClusterLabel(
|
||||
location.properties,
|
||||
aggregation,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const finiteClusterLabels = clusterLabelMap
|
||||
.map(value => Number(value))
|
||||
.filter(value => Number.isFinite(value));
|
||||
const safeMaxAbsLabel =
|
||||
finiteClusterLabels.length > 0
|
||||
? Math.max(
|
||||
Math.max(...finiteClusterLabels.map(value => Math.abs(value))),
|
||||
1,
|
||||
)
|
||||
: 1;
|
||||
|
||||
// Calculate min/max radius values for Pixels mode scaling
|
||||
let minRadiusValue = Infinity;
|
||||
let maxRadiusValue = -Infinity;
|
||||
if (pointRadiusUnit === 'Pixels') {
|
||||
locations.forEach(location => {
|
||||
if (
|
||||
!location.properties.cluster &&
|
||||
location.properties.radius != null
|
||||
) {
|
||||
const radiusValueRaw = location.properties.radius;
|
||||
const radiusValue =
|
||||
typeof radiusValueRaw === 'string' && radiusValueRaw.trim() === ''
|
||||
? null
|
||||
: Number(radiusValueRaw);
|
||||
if (radiusValue != null && Number.isFinite(radiusValue)) {
|
||||
minRadiusValue = Math.min(minRadiusValue, radiusValue);
|
||||
maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.globalCompositeOperation =
|
||||
compositeOperation as GlobalCompositeOperation;
|
||||
|
||||
if ((renderWhileDragging || !isDragging) && locations) {
|
||||
locations.forEach((location: GeoJSONLocation, i: number) => {
|
||||
const pixel = project(lngLatAccessor(location));
|
||||
const pixelRounded: [number, number] = [
|
||||
roundDecimal(pixel[0], 1),
|
||||
roundDecimal(pixel[1], 1),
|
||||
];
|
||||
|
||||
if (
|
||||
pixelRounded[0] + radius >= 0 &&
|
||||
pixelRounded[0] - radius < width &&
|
||||
pixelRounded[1] + radius >= 0 &&
|
||||
pixelRounded[1] - radius < height
|
||||
) {
|
||||
ctx.beginPath();
|
||||
if (location.properties.cluster) {
|
||||
const clusterLabel = clusterLabelMap[i];
|
||||
const numericLabel = Number(clusterLabel);
|
||||
const safeNumericLabel = Number.isFinite(numericLabel)
|
||||
? numericLabel
|
||||
: 0;
|
||||
const minClusterRadius =
|
||||
pointRadiusUnit === 'Pixels'
|
||||
? radius * MAX_POINT_RADIUS_RATIO
|
||||
: radius * MIN_CLUSTER_RADIUS_RATIO;
|
||||
const ratio = Math.abs(safeNumericLabel) / safeMaxAbsLabel;
|
||||
const scaledRadius = roundDecimal(
|
||||
minClusterRadius + ratio ** 0.5 * (radius - minClusterRadius),
|
||||
1,
|
||||
);
|
||||
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
|
||||
const [x, y] = pixelRounded;
|
||||
const gradient = ctx.createRadialGradient(
|
||||
x,
|
||||
y,
|
||||
scaledRadius,
|
||||
x,
|
||||
y,
|
||||
0,
|
||||
);
|
||||
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * globalOpacity})`,
|
||||
);
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
|
||||
);
|
||||
ctx.arc(
|
||||
pixelRounded[0],
|
||||
pixelRounded[1],
|
||||
scaledRadius,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
if (Number.isFinite(safeNumericLabel)) {
|
||||
let label: string | number = clusterLabel;
|
||||
const absLabel = Math.abs(safeNumericLabel);
|
||||
const sign = safeNumericLabel < 0 ? '-' : '';
|
||||
if (absLabel >= 10000) {
|
||||
label = `${sign}${Math.round(absLabel / 1000)}k`;
|
||||
} else if (absLabel >= 1000) {
|
||||
label = `${sign}${Math.round(absLabel / 100) / 10}k`;
|
||||
}
|
||||
drawText(ctx, pixelRounded, compositeOperation, {
|
||||
fontHeight,
|
||||
label,
|
||||
radius: scaledRadius,
|
||||
rgb,
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const defaultRadius = radius * MIN_CLUSTER_RADIUS_RATIO;
|
||||
const rawRadius = location.properties.radius;
|
||||
const numericRadiusProperty =
|
||||
rawRadius != null &&
|
||||
!(typeof rawRadius === 'string' && rawRadius.trim() === '')
|
||||
? Number(rawRadius)
|
||||
: null;
|
||||
const radiusProperty =
|
||||
numericRadiusProperty != null &&
|
||||
Number.isFinite(numericRadiusProperty)
|
||||
? numericRadiusProperty
|
||||
: null;
|
||||
const pointMetric = location.properties.metric ?? null;
|
||||
let pointRadius: number = radiusProperty ?? defaultRadius;
|
||||
let pointLabel: string | number | undefined;
|
||||
|
||||
if (radiusProperty != null) {
|
||||
const pointLatitude = lngLatAccessor(location)[1];
|
||||
if (pointRadiusUnit === 'Kilometers') {
|
||||
pointLabel = `${roundDecimal(pointRadius, 2)}km`;
|
||||
pointRadius = kmToPixels(
|
||||
pointRadius,
|
||||
pointLatitude,
|
||||
zoom ?? 0,
|
||||
);
|
||||
} else if (pointRadiusUnit === 'Miles') {
|
||||
pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
|
||||
pointRadius = kmToPixels(
|
||||
pointRadius * MILES_PER_KM,
|
||||
pointLatitude,
|
||||
zoom ?? 0,
|
||||
);
|
||||
} else if (pointRadiusUnit === 'Pixels') {
|
||||
const MIN_POINT_RADIUS = radius * MIN_CLUSTER_RADIUS_RATIO;
|
||||
const MAX_POINT_RADIUS = radius * MAX_POINT_RADIUS_RATIO;
|
||||
|
||||
if (
|
||||
Number.isFinite(minRadiusValue) &&
|
||||
Number.isFinite(maxRadiusValue) &&
|
||||
maxRadiusValue > minRadiusValue
|
||||
) {
|
||||
const numericPointRadius = Number(pointRadius);
|
||||
if (!Number.isFinite(numericPointRadius)) {
|
||||
pointRadius = MIN_POINT_RADIUS;
|
||||
} else {
|
||||
const normalizedValueRaw =
|
||||
(numericPointRadius - minRadiusValue) /
|
||||
(maxRadiusValue - minRadiusValue);
|
||||
const normalizedValue = Math.max(
|
||||
0,
|
||||
Math.min(1, normalizedValueRaw),
|
||||
);
|
||||
pointRadius =
|
||||
MIN_POINT_RADIUS +
|
||||
normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
|
||||
}
|
||||
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
|
||||
} else if (
|
||||
Number.isFinite(minRadiusValue) &&
|
||||
minRadiusValue === maxRadiusValue
|
||||
) {
|
||||
pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
|
||||
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
|
||||
} else {
|
||||
pointRadius = Math.max(
|
||||
MIN_POINT_RADIUS,
|
||||
Math.min(pointRadius, MAX_POINT_RADIUS),
|
||||
);
|
||||
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pointMetric !== null) {
|
||||
const numericMetric = parseFloat(String(pointMetric));
|
||||
pointLabel = Number.isFinite(numericMetric)
|
||||
? roundDecimal(numericMetric, 2)
|
||||
: String(pointMetric);
|
||||
}
|
||||
|
||||
if (!pointRadius) {
|
||||
pointRadius = defaultRadius;
|
||||
}
|
||||
|
||||
ctx.arc(
|
||||
pixelRounded[0],
|
||||
pixelRounded[1],
|
||||
roundDecimal(pointRadius, 1),
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
|
||||
ctx.fill();
|
||||
|
||||
if (pointLabel !== undefined) {
|
||||
drawText(ctx, pixelRounded, compositeOperation, {
|
||||
fontHeight: roundDecimal(pointRadius, 1),
|
||||
label: pointLabel,
|
||||
radius: pointRadius,
|
||||
rgb,
|
||||
shadow: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
aggregation,
|
||||
compositeOperation,
|
||||
dotRadius,
|
||||
globalOpacity,
|
||||
lngLatAccessor,
|
||||
locations,
|
||||
pointRadiusUnit,
|
||||
renderWhileDragging,
|
||||
rgb,
|
||||
zoom,
|
||||
],
|
||||
);
|
||||
|
||||
return <CanvasOverlay redraw={redraw} />;
|
||||
}
|
||||
|
||||
export default memo(ScatterPlotOverlay);
|
||||
@@ -17,7 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { validateMapboxStylesUrl } from '@superset-ui/core';
|
||||
import {
|
||||
columnChoices,
|
||||
ControlPanelConfig,
|
||||
@@ -29,12 +28,12 @@ import {
|
||||
const columnsConfig = sharedControls.entity;
|
||||
|
||||
const colorChoices = [
|
||||
['rgb(0, 139, 139)', t('Dark Cyan')],
|
||||
['rgb(128, 0, 128)', t('Purple')],
|
||||
['rgb(255, 215, 0)', t('Gold')],
|
||||
['rgb(69, 69, 69)', t('Dim Gray')],
|
||||
['rgb(220, 20, 60)', t('Crimson')],
|
||||
['rgb(34, 139, 34)', t('Forest Green')],
|
||||
['#008b8b', t('Dark Cyan')],
|
||||
['#800080', t('Purple')],
|
||||
['#ffd700', t('Gold')],
|
||||
['#454545', t('Dim Gray')],
|
||||
['#dc143c', t('Crimson')],
|
||||
['#228b22', t('Forest Green')],
|
||||
];
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
@@ -110,7 +109,7 @@ const config: ControlPanelConfig = {
|
||||
'Either a numerical column or `Auto`, which scales the point based ' +
|
||||
'on the largest cluster',
|
||||
),
|
||||
mapStateToProps: state => {
|
||||
mapStateToProps: (state: any) => {
|
||||
const datasourceChoices = columnChoices(state.datasource);
|
||||
const choices: [string, string][] = [['Auto', t('Auto')]];
|
||||
return {
|
||||
@@ -145,7 +144,7 @@ const config: ControlPanelConfig = {
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'mapbox_label',
|
||||
name: 'map_label',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
multi: true,
|
||||
@@ -157,7 +156,7 @@ const config: ControlPanelConfig = {
|
||||
'Non-numerical columns will be used to label points. ' +
|
||||
'Leave empty to get a count of points in each cluster.',
|
||||
),
|
||||
mapStateToProps: state => ({
|
||||
mapStateToProps: (state: any) => ({
|
||||
choices: columnChoices(state.datasource),
|
||||
}),
|
||||
},
|
||||
@@ -189,21 +188,66 @@ const config: ControlPanelConfig = {
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual Tweaks'),
|
||||
label: t('Map'),
|
||||
tabOverride: 'customize',
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'render_while_dragging',
|
||||
name: 'map_renderer',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Live render'),
|
||||
default: true,
|
||||
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(
|
||||
'Points and clusters will update as the viewport is being changed',
|
||||
'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',
|
||||
@@ -213,22 +257,42 @@ const config: ControlPanelConfig = {
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
freeForm: true,
|
||||
validators: [validateMapboxStylesUrl],
|
||||
choices: [
|
||||
['mapbox://styles/mapbox/streets-v9', t('Streets')],
|
||||
['mapbox://styles/mapbox/dark-v9', t('Dark')],
|
||||
['mapbox://styles/mapbox/light-v9', t('Light')],
|
||||
['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-v9',
|
||||
'mapbox://styles/mapbox/satellite-streets-v12',
|
||||
t('Satellite Streets'),
|
||||
],
|
||||
['mapbox://styles/mapbox/satellite-v9', t('Satellite')],
|
||||
['mapbox://styles/mapbox/outdoors-v9', t('Outdoors')],
|
||||
],
|
||||
default: 'mapbox://styles/mapbox/light-v9',
|
||||
default: 'mapbox://styles/mapbox/light-v11',
|
||||
description: t(
|
||||
'Base layer map style. See Mapbox documentation: %s',
|
||||
'https://docs.mapbox.com/help/glossary/style-url/',
|
||||
'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',
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -239,9 +303,9 @@ const config: ControlPanelConfig = {
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Opacity'),
|
||||
renderTrigger: true,
|
||||
default: 1,
|
||||
isFloat: true,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Opacity of all clusters, points, and labels. Between 0 and 1.',
|
||||
),
|
||||
@@ -250,10 +314,11 @@ const config: ControlPanelConfig = {
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'mapbox_color',
|
||||
name: 'map_color',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
renderTrigger: true,
|
||||
label: t('RGB Color'),
|
||||
default: colorChoices[0][0],
|
||||
choices: colorChoices,
|
||||
@@ -278,7 +343,6 @@ const config: ControlPanelConfig = {
|
||||
isFloat: true,
|
||||
description: t('Longitude of default viewport'),
|
||||
places: 8,
|
||||
// Viewport longitude changes shouldn't prompt user to re-run query
|
||||
dontRefreshOnChange: true,
|
||||
},
|
||||
},
|
||||
@@ -292,7 +356,6 @@ const config: ControlPanelConfig = {
|
||||
isFloat: true,
|
||||
description: t('Latitude of default viewport'),
|
||||
places: 8,
|
||||
// Viewport latitude changes shouldn't prompt user to re-run query
|
||||
dontRefreshOnChange: true,
|
||||
},
|
||||
},
|
||||
@@ -308,7 +371,6 @@ const config: ControlPanelConfig = {
|
||||
default: '',
|
||||
description: t('Zoom level of the map'),
|
||||
places: 8,
|
||||
// Viewport zoom shouldn't prompt user to re-run query
|
||||
dontRefreshOnChange: true,
|
||||
},
|
||||
},
|
||||
@@ -325,7 +387,7 @@ const config: ControlPanelConfig = {
|
||||
),
|
||||
},
|
||||
},
|
||||
formDataOverrides: formData => ({
|
||||
formDataOverrides: (formData: any) => ({
|
||||
...formData,
|
||||
groupby: getStandardizedControls().popAllColumns(),
|
||||
}),
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
@@ -28,31 +28,30 @@ import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
category: t('Map'),
|
||||
credits: ['https://www.mapbox.com/mapbox-gl-js/api/'],
|
||||
credits: ['https://maplibre.org/'],
|
||||
description: '',
|
||||
exampleGallery: [
|
||||
{ url: example1, urlDark: example1Dark, caption: t('Light mode') },
|
||||
{ url: example2, urlDark: example2Dark, caption: t('Dark mode') },
|
||||
],
|
||||
name: t('MapBox'),
|
||||
name: t('Point Cluster Map'),
|
||||
tags: [
|
||||
t('Business'),
|
||||
t('Intensity'),
|
||||
t('Legacy'),
|
||||
t('Density'),
|
||||
t('Scatter'),
|
||||
t('Transformable'),
|
||||
],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
export default class MapBoxChartPlugin extends ChartPlugin {
|
||||
export default class ScatterMapChartPlugin extends ChartPlugin {
|
||||
constructor() {
|
||||
super({
|
||||
loadChart: () => import('./MapBox'),
|
||||
loadChart: () => import('./MapLibre'),
|
||||
loadTransformProps: () => import('./transformProps'),
|
||||
loadBuildQuery: () => import('./buildQuery'),
|
||||
metadata,
|
||||
controlPanel,
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable sort-keys, no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import ScatterMapChartPlugin from '@superset-ui/plugin-chart-point-cluster-map';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import { generateData } from './data';
|
||||
|
||||
new ScatterMapChartPlugin().configure({ key: 'point_cluster_map' }).register();
|
||||
|
||||
export default {
|
||||
title: 'Chart Plugins/plugin-chart-point-cluster-map',
|
||||
decorators: [withResizableChartDemo],
|
||||
args: {
|
||||
clusteringRadius: 60,
|
||||
globalOpacity: 1,
|
||||
pointRadius: 'Auto',
|
||||
renderWhileDragging: true,
|
||||
mapRenderer: 'maplibre',
|
||||
},
|
||||
argTypes: {
|
||||
clusteringRadius: {
|
||||
control: { type: 'range', min: 0, max: 200, step: 10 },
|
||||
description: 'Radius in pixels for clustering points',
|
||||
},
|
||||
globalOpacity: {
|
||||
control: { type: 'range', min: 0, max: 1, step: 0.1 },
|
||||
description: 'Opacity of map markers',
|
||||
},
|
||||
pointRadius: {
|
||||
control: 'select',
|
||||
options: ['Auto', 1, 2, 5, 10, 20, 50],
|
||||
description: 'Size of point markers',
|
||||
},
|
||||
renderWhileDragging: {
|
||||
control: 'boolean',
|
||||
description: 'Render markers while dragging the map',
|
||||
},
|
||||
mapRenderer: {
|
||||
control: 'select',
|
||||
options: ['maplibre', 'mapbox'],
|
||||
description:
|
||||
'Map renderer. MapLibre is open-source. Mapbox requires MAPBOX_API_KEY.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InteractiveSuperclusterMap = ({
|
||||
clusteringRadius,
|
||||
globalOpacity,
|
||||
pointRadius,
|
||||
renderWhileDragging,
|
||||
mapRenderer,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
clusteringRadius: number;
|
||||
globalOpacity: number;
|
||||
pointRadius: string | number;
|
||||
renderWhileDragging: boolean;
|
||||
mapRenderer: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<SuperChart
|
||||
chartType="point_cluster_map"
|
||||
width={width}
|
||||
height={height}
|
||||
queriesData={[{ data: generateData(theme) }]}
|
||||
formData={{
|
||||
clustering_radius: String(clusteringRadius),
|
||||
global_opacity: globalOpacity,
|
||||
map_color: '#008b8b',
|
||||
map_label: [],
|
||||
map_renderer: mapRenderer,
|
||||
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||
mapbox_style: 'mapbox://styles/mapbox/light-v11',
|
||||
pandas_aggfunc: 'sum',
|
||||
point_radius: pointRadius,
|
||||
point_radius_unit: 'Pixels',
|
||||
render_while_dragging: renderWhileDragging,
|
||||
viewport_latitude: 37.78,
|
||||
viewport_longitude: -122.42,
|
||||
viewport_zoom: 12,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithMetricLabels = ({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<SuperChart
|
||||
chartType="point_cluster_map"
|
||||
width={width}
|
||||
height={height}
|
||||
queriesData={[{ data: generateData(theme) }]}
|
||||
formData={{
|
||||
clustering_radius: '60',
|
||||
global_opacity: 1,
|
||||
map_color: '#dc143c',
|
||||
map_label: ['metric'],
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style:
|
||||
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
||||
pandas_aggfunc: 'sum',
|
||||
point_radius: 'Auto',
|
||||
point_radius_unit: 'Pixels',
|
||||
render_while_dragging: true,
|
||||
viewport_latitude: 37.78,
|
||||
viewport_longitude: -122.42,
|
||||
viewport_zoom: 12,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoClustering = ({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<SuperChart
|
||||
chartType="point_cluster_map"
|
||||
width={width}
|
||||
height={height}
|
||||
queriesData={[{ data: generateData(theme) }]}
|
||||
formData={{
|
||||
clustering_radius: '0',
|
||||
global_opacity: 0.8,
|
||||
map_color: '#228b22',
|
||||
map_label: [],
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style:
|
||||
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
|
||||
pandas_aggfunc: 'sum',
|
||||
point_radius: 'Auto',
|
||||
point_radius_unit: 'Pixels',
|
||||
render_while_dragging: true,
|
||||
viewport_latitude: 37.78,
|
||||
viewport_longitude: -122.42,
|
||||
viewport_zoom: 12,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -16,41 +16,20 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import getPointsFromPolygon from '../../src/utils/getPointsFromPolygon';
|
||||
|
||||
describe('getPointsFromPolygon', () => {
|
||||
test('handle original input', () => {
|
||||
expect(
|
||||
getPointsFromPolygon({
|
||||
polygon: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
]);
|
||||
});
|
||||
test('handle geojson features', () => {
|
||||
expect(
|
||||
getPointsFromPolygon({
|
||||
polygon: {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
]);
|
||||
});
|
||||
});
|
||||
export function getMapboxApiKey(): string {
|
||||
if (typeof document === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const appContainer = document.getElementById('app');
|
||||
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
|
||||
if (dataBootstrap) {
|
||||
const bootstrapData = JSON.parse(dataBootstrap);
|
||||
return bootstrapData?.common?.conf?.MAPBOX_API_KEY || '';
|
||||
}
|
||||
} catch {
|
||||
// If bootstrap data is unavailable or malformed, return empty string
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* 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 { type ReactNode } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
// Capture the most recent viewport props passed to the Map component
|
||||
let lastMapProps: Record<string, unknown> = {};
|
||||
const mockFitBounds = jest.fn();
|
||||
|
||||
jest.mock('react-map-gl/maplibre', () => {
|
||||
const MockMap = (props: Record<string, unknown>) => {
|
||||
lastMapProps = props;
|
||||
return <div data-testid="map-gl">{props.children as ReactNode}</div>;
|
||||
};
|
||||
return { __esModule: true, Map: MockMap };
|
||||
});
|
||||
|
||||
jest.mock('react-map-gl/mapbox', () => {
|
||||
const MockMap = (props: Record<string, unknown>) => {
|
||||
lastMapProps = props;
|
||||
return <div data-testid="map-gl">{props.children as ReactNode}</div>;
|
||||
};
|
||||
return { __esModule: true, Map: MockMap };
|
||||
});
|
||||
|
||||
jest.mock('@math.gl/web-mercator', () => ({
|
||||
WebMercatorViewport: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
({ width, height }: { width: number; height: number }) => ({
|
||||
fitBounds: (bounds: [[number, number], [number, number]]) =>
|
||||
mockFitBounds(bounds, width, height),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../src/components/ScatterPlotOverlay', () => {
|
||||
const MockOverlay = (props: Record<string, unknown>) => (
|
||||
<div data-testid="scatter-overlay" data-opacity={props.globalOpacity} />
|
||||
);
|
||||
return { __esModule: true, default: MockOverlay };
|
||||
});
|
||||
|
||||
jest.mock('@apache-superset/core/theme', () => ({
|
||||
useTheme: () => ({ colorTextSecondary: '#666' }),
|
||||
}));
|
||||
|
||||
jest.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
jest.mock('../src/MapLibre.css', () => ({}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import MapLibre from '../src/MapLibre';
|
||||
|
||||
const defaultProps = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
clusterer: {
|
||||
getClusters: jest.fn().mockReturnValue([]),
|
||||
},
|
||||
globalOpacity: 1,
|
||||
mapProvider: 'maplibre',
|
||||
mapStyle: 'https://tiles.openfreemap.org/styles/liberty',
|
||||
pointRadius: 60,
|
||||
pointRadiusUnit: 'Pixels',
|
||||
renderWhileDragging: true,
|
||||
rgb: ['', 255, 0, 0] as (string | number)[],
|
||||
hasCustomMetric: false,
|
||||
bounds: [
|
||||
[-74.0, 40.7],
|
||||
[-73.9, 40.8],
|
||||
] as [[number, number], [number, number]],
|
||||
onViewportChange: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
lastMapProps = {};
|
||||
jest.clearAllMocks();
|
||||
mockFitBounds.mockImplementation(
|
||||
(
|
||||
bounds: [[number, number], [number, number]],
|
||||
width: number,
|
||||
height: number,
|
||||
) => ({
|
||||
latitude: Number(((bounds[0][1] + bounds[1][1]) / 2).toFixed(2)),
|
||||
longitude: Number(((bounds[0][0] + bounds[1][0]) / 2).toFixed(2)),
|
||||
zoom: Number((10 + width / 1000 + height / 10000).toFixed(2)),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('initializes viewport from bounds', () => {
|
||||
render(<MapLibre {...defaultProps} />);
|
||||
expect(lastMapProps.longitude).toBe(-73.95);
|
||||
expect(lastMapProps.latitude).toBe(40.75);
|
||||
expect(lastMapProps.zoom).toBe(10.86);
|
||||
});
|
||||
|
||||
test('initializes viewport from props when provided', () => {
|
||||
render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
expect(lastMapProps.longitude).toBe(-122.4);
|
||||
expect(lastMapProps.latitude).toBe(37.8);
|
||||
expect(lastMapProps.zoom).toBe(5);
|
||||
});
|
||||
|
||||
test('updates viewport when viewport props change', () => {
|
||||
const { rerender } = render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
viewportLongitude={-73.95}
|
||||
viewportLatitude={40.75}
|
||||
viewportZoom={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapProps.longitude).toBe(-122.4);
|
||||
expect(lastMapProps.latitude).toBe(37.8);
|
||||
expect(lastMapProps.zoom).toBe(5);
|
||||
});
|
||||
|
||||
test('does not loop when viewport state matches new props', () => {
|
||||
const { rerender } = render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
viewportLongitude={-73.95}
|
||||
viewportLatitude={40.75}
|
||||
viewportZoom={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
viewportLongitude={-73.95}
|
||||
viewportLatitude={40.75}
|
||||
viewportZoom={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastMapProps.longitude).toBe(-73.95);
|
||||
expect(lastMapProps.latitude).toBe(40.75);
|
||||
expect(lastMapProps.zoom).toBe(10);
|
||||
});
|
||||
|
||||
test('passes globalOpacity to ScatterPlotOverlay', () => {
|
||||
const { container } = render(
|
||||
<MapLibre {...defaultProps} globalOpacity={0.5} />,
|
||||
);
|
||||
const overlay = container.querySelector('[data-testid="scatter-overlay"]');
|
||||
expect(overlay).not.toBeNull();
|
||||
expect(overlay!.getAttribute('data-opacity')).toBe('0.5');
|
||||
});
|
||||
|
||||
test('handles undefined bounds gracefully', () => {
|
||||
render(<MapLibre {...defaultProps} bounds={undefined} />);
|
||||
expect(lastMapProps.longitude).toBe(0);
|
||||
expect(lastMapProps.latitude).toBe(0);
|
||||
expect(lastMapProps.zoom).toBe(1);
|
||||
});
|
||||
|
||||
test('applies partial viewport props on update', () => {
|
||||
const { rerender } = render(<MapLibre {...defaultProps} />);
|
||||
|
||||
rerender(<MapLibre {...defaultProps} viewportLongitude={-122.4} />);
|
||||
|
||||
expect(lastMapProps.longitude).toBe(-122.4);
|
||||
// lat and zoom come from fitBounds
|
||||
expect(lastMapProps.latitude).toBe(40.75);
|
||||
expect(lastMapProps.zoom).toBe(10.86);
|
||||
});
|
||||
|
||||
test('restores fitBounds when viewport props are cleared', () => {
|
||||
const { rerender } = render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear all viewport props
|
||||
rerender(<MapLibre {...defaultProps} />);
|
||||
|
||||
// Should revert to fitBounds values
|
||||
expect(lastMapProps.longitude).toBe(-73.95);
|
||||
expect(lastMapProps.latitude).toBe(40.75);
|
||||
expect(lastMapProps.zoom).toBe(10.86);
|
||||
});
|
||||
|
||||
test('restores only cleared viewport props, keeps the rest', () => {
|
||||
const { rerender } = render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear only longitude, keep lat/zoom
|
||||
rerender(
|
||||
<MapLibre {...defaultProps} viewportLatitude={37.8} viewportZoom={5} />,
|
||||
);
|
||||
|
||||
// Longitude reverts to fitBounds, lat/zoom stay
|
||||
expect(lastMapProps.longitude).toBe(-73.95);
|
||||
expect(lastMapProps.latitude).toBe(37.8);
|
||||
expect(lastMapProps.zoom).toBe(5);
|
||||
});
|
||||
|
||||
test('falls back to default viewport when cleared with undefined bounds', () => {
|
||||
const { rerender } = render(
|
||||
<MapLibre
|
||||
{...defaultProps}
|
||||
bounds={undefined}
|
||||
viewportLongitude={-122.4}
|
||||
viewportLatitude={37.8}
|
||||
viewportZoom={5}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear viewport props — no bounds to fitBounds to
|
||||
rerender(<MapLibre {...defaultProps} bounds={undefined} />);
|
||||
|
||||
// Should fall back to {0, 0, 1}
|
||||
expect(lastMapProps.longitude).toBe(0);
|
||||
expect(lastMapProps.latitude).toBe(0);
|
||||
expect(lastMapProps.zoom).toBe(1);
|
||||
});
|
||||
@@ -18,7 +18,11 @@
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import ScatterPlotGlowOverlay from '../src/ScatterPlotGlowOverlay';
|
||||
import ScatterPlotOverlay from '../src/components/ScatterPlotOverlay';
|
||||
import {
|
||||
MIN_CLUSTER_RADIUS_RATIO,
|
||||
MAX_POINT_RADIUS_RATIO,
|
||||
} from '../src/components/ScatterPlotOverlay';
|
||||
|
||||
type MockGradient = {
|
||||
addColorStop: jest.Mock<void, [number, string]>;
|
||||
@@ -67,22 +71,20 @@ declare global {
|
||||
var mockRedraw: unknown;
|
||||
}
|
||||
|
||||
// Mock react-map-gl's CanvasOverlay
|
||||
jest.mock('react-map-gl', () => ({
|
||||
CanvasOverlay: ({ redraw }: { redraw: unknown }) => {
|
||||
// Store the redraw function so tests can call it
|
||||
// Mock the CanvasOverlay component to capture the redraw function
|
||||
jest.mock('../src/components/CanvasOverlay', () => ({
|
||||
__esModule: true,
|
||||
default: ({ redraw }: { redraw: unknown }) => {
|
||||
global.mockRedraw = redraw;
|
||||
return <div data-testid="canvas-overlay" />;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock utility functions
|
||||
jest.mock('../src/utils/luminanceFromRGB', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => 150), // Return a value above the dark threshold
|
||||
default: jest.fn(() => 150),
|
||||
}));
|
||||
|
||||
// Test helpers
|
||||
const createMockCanvas = () => {
|
||||
const ctx: MockCanvasContext = {
|
||||
clearRect: jest.fn(),
|
||||
@@ -151,8 +153,10 @@ const defaultProps = {
|
||||
rgb: ['', 255, 0, 0] as [string, number, number, number],
|
||||
globalOpacity: 1,
|
||||
};
|
||||
const MIN_VISIBLE_POINT_RADIUS = 10;
|
||||
const MAX_VISIBLE_POINT_RADIUS = 20;
|
||||
const MIN_VISIBLE_POINT_RADIUS =
|
||||
defaultProps.dotRadius * MIN_CLUSTER_RADIUS_RATIO;
|
||||
const MAX_VISIBLE_POINT_RADIUS =
|
||||
defaultProps.dotRadius * MAX_POINT_RADIUS_RATIO;
|
||||
|
||||
test('renders map with varying radius values in Pixels mode', () => {
|
||||
const locations = [
|
||||
@@ -162,7 +166,7 @@ test('renders map with varying radius values in Pixels mode', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -172,13 +176,11 @@ test('renders map with varying radius values in Pixels mode', () => {
|
||||
|
||||
const arcCalls = redrawParams.ctx.arc.mock.calls;
|
||||
|
||||
// With dotRadius=60, pixel-sized points should map to the visible 10-20 range.
|
||||
arcCalls.forEach(call => {
|
||||
expect(call[2]).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
|
||||
expect(call[2]).toBeLessThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
|
||||
});
|
||||
|
||||
// Ordering should be preserved: radius 10 < 50 < 100
|
||||
expect(arcCalls[0][2]).toBeLessThan(arcCalls[1][2]);
|
||||
expect(arcCalls[1][2]).toBeLessThan(arcCalls[2][2]);
|
||||
});
|
||||
@@ -192,7 +194,7 @@ test('handles dataset with uniform radius values', () => {
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -211,7 +213,7 @@ test('renders successfully when data contains non-finite values', () => {
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -229,7 +231,7 @@ test('handles radius values provided as strings', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -256,7 +258,7 @@ test('treats blank radius strings as missing values', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -280,7 +282,7 @@ test('renders points when radius values are missing', () => {
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -304,7 +306,7 @@ test('renders both cluster and non-cluster points correctly', () => {
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -323,7 +325,7 @@ test('renders map with multiple points with different radius values', () => {
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -341,7 +343,7 @@ test('renders map with Kilometers mode', () => {
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Kilometers"
|
||||
@@ -360,7 +362,7 @@ test('renders map with Miles mode', () => {
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Miles"
|
||||
@@ -378,7 +380,7 @@ test('displays metric property labels on points', () => {
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -391,7 +393,7 @@ test('displays metric property labels on points', () => {
|
||||
test('handles empty dataset without errors', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={[]}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -410,7 +412,7 @@ test('handles extreme outlier radius values without breaking', () => {
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -431,7 +433,7 @@ test('renders successfully with mixed extreme and negative radius values', () =>
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
pointRadiusUnit="Pixels"
|
||||
@@ -456,7 +458,7 @@ test('cluster radius is always >= max individual point radius in Pixels mode', (
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -467,9 +469,7 @@ test('cluster radius is always >= max individual point radius in Pixels mode', (
|
||||
|
||||
const arcCalls = redrawParams.ctx.arc.mock.calls;
|
||||
|
||||
// cluster with label=1 (index 0) should not be smaller than the largest point bubble
|
||||
expect(arcCalls[0][2]).toBeGreaterThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
|
||||
// point radii span the configured pixel range
|
||||
expect(arcCalls[1][2]).toBe(MIN_VISIBLE_POINT_RADIUS);
|
||||
expect(arcCalls[2][2]).toBe(MAX_VISIBLE_POINT_RADIUS);
|
||||
expect(arcCalls[0][2]).toBeGreaterThanOrEqual(arcCalls[2][2]);
|
||||
@@ -490,7 +490,7 @@ test('largest cluster gets full dotRadius', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -500,7 +500,6 @@ test('largest cluster gets full dotRadius', () => {
|
||||
const redrawParams = triggerRedraw();
|
||||
|
||||
const arcCalls = redrawParams.ctx.arc.mock.calls;
|
||||
// The largest cluster (label=100, maxLabel=100) should get full radius
|
||||
expect(arcCalls[1][2]).toBe(defaultProps.dotRadius);
|
||||
});
|
||||
|
||||
@@ -524,7 +523,7 @@ test('cluster radii preserve proportional ordering', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -552,7 +551,7 @@ test('negative cluster label produces valid finite radius', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -581,7 +580,7 @@ test('ignores non-finite cluster labels when computing cluster scaling bounds',
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -606,7 +605,7 @@ test('single cluster with small maxLabel gets full dotRadius', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -615,7 +614,6 @@ test('single cluster with small maxLabel gets full dotRadius', () => {
|
||||
const redrawParams = triggerRedraw();
|
||||
|
||||
const arcCalls = redrawParams.ctx.arc.mock.calls;
|
||||
// When there's only one cluster, label=maxLabel, so it gets full radius
|
||||
expect(arcCalls[0][2]).toBe(defaultProps.dotRadius);
|
||||
});
|
||||
|
||||
@@ -639,7 +637,7 @@ test('all-negative cluster labels produce differentiated radii by magnitude', ()
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -652,7 +650,6 @@ test('all-negative cluster labels produce differentiated radii by magnitude', ()
|
||||
const rNeg10 = arcCalls[1][2];
|
||||
const rNeg1 = arcCalls[2][2];
|
||||
|
||||
// Higher magnitude = bigger circle: |-100| > |-10| > |-1|
|
||||
expect(rNeg1).toBeLessThan(rNeg10);
|
||||
expect(rNeg10).toBeLessThan(rNeg100);
|
||||
expect(Number.isFinite(rNeg100)).toBe(true);
|
||||
@@ -682,7 +679,7 @@ test('mixed positive-and-negative cluster labels size by magnitude', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -695,7 +692,6 @@ test('mixed positive-and-negative cluster labels size by magnitude', () => {
|
||||
const rZero = arcCalls[1][2];
|
||||
const r100 = arcCalls[2][2];
|
||||
|
||||
// Magnitude ordering: |0| < |-50| < |100|
|
||||
expect(rZero).toBeLessThan(rNeg50);
|
||||
expect(rNeg50).toBeLessThan(r100);
|
||||
expect(rZero).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
|
||||
@@ -722,7 +718,7 @@ test('all-identical negative labels get equal full radii', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -750,7 +746,7 @@ test('single negative cluster gets full radius', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -772,7 +768,7 @@ test('large negative cluster labels are abbreviated', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -812,7 +808,7 @@ test.each([
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation={aggregation}
|
||||
@@ -846,7 +842,7 @@ test('zero-value cluster is visible with minimum radius', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -877,7 +873,7 @@ test('all-zero clusters use a finite radius', () => {
|
||||
];
|
||||
|
||||
render(
|
||||
<ScatterPlotGlowOverlay
|
||||
<ScatterPlotOverlay
|
||||
{...defaultProps}
|
||||
locations={locations}
|
||||
aggregation="sum"
|
||||
@@ -42,19 +42,23 @@ type TransformPropsResult = {
|
||||
viewportLongitude?: number;
|
||||
viewportLatitude?: number;
|
||||
viewportZoom?: number;
|
||||
rgb?: string[] | null;
|
||||
};
|
||||
|
||||
const baseFormData = {
|
||||
clusteringRadius: 60,
|
||||
globalOpacity: 0.8,
|
||||
mapboxColor: 'rgb(0, 139, 139)',
|
||||
mapboxStyle: 'mapbox://styles/mapbox/light-v9',
|
||||
pandasAggfunc: 'sum',
|
||||
pointRadiusUnit: 'Pixels',
|
||||
renderWhileDragging: true,
|
||||
viewportLongitude: -73.935242,
|
||||
viewportLatitude: 40.73061,
|
||||
viewportZoom: 9,
|
||||
all_columns_x: 'lon',
|
||||
all_columns_y: 'lat',
|
||||
clustering_radius: 60,
|
||||
global_opacity: 0.8,
|
||||
map_color: 'rgb(0, 139, 139)',
|
||||
map_renderer: 'maplibre',
|
||||
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||
pandas_aggfunc: 'sum',
|
||||
point_radius_unit: 'Pixels',
|
||||
render_while_dragging: true,
|
||||
viewport_longitude: -73.935242,
|
||||
viewport_latitude: 40.73061,
|
||||
viewport_zoom: 9,
|
||||
};
|
||||
|
||||
const baseQueriesData = [
|
||||
@@ -66,7 +70,6 @@ const baseQueriesData = [
|
||||
] as [[number, number], [number, number]],
|
||||
geoJSON: { features: [] },
|
||||
hasCustomMetric: false,
|
||||
mapboxApiKey: 'test-api-key',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -88,15 +91,15 @@ function getTransformPropsResult(
|
||||
}
|
||||
|
||||
test('extracts globalOpacity from formData', () => {
|
||||
const result = getTransformPropsResult({ globalOpacity: 0.5 });
|
||||
const result = getTransformPropsResult({ global_opacity: 0.5 });
|
||||
expect(result.globalOpacity).toBe(0.5);
|
||||
});
|
||||
|
||||
test('extracts viewport values from formData', () => {
|
||||
const result = getTransformPropsResult({
|
||||
viewportLongitude: -122.4,
|
||||
viewportLatitude: 37.8,
|
||||
viewportZoom: 12,
|
||||
viewport_longitude: -122.4,
|
||||
viewport_latitude: 37.8,
|
||||
viewport_zoom: 12,
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -109,9 +112,9 @@ test('extracts viewport values from formData', () => {
|
||||
|
||||
test('clamps viewport values to safe map ranges', () => {
|
||||
const result = getTransformPropsResult({
|
||||
viewportLongitude: 190,
|
||||
viewportLatitude: -100,
|
||||
viewportZoom: 99,
|
||||
viewport_longitude: 190,
|
||||
viewport_latitude: -100,
|
||||
viewport_zoom: 99,
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -148,9 +151,9 @@ test('provides onViewportChange callback that updates control values', () => {
|
||||
|
||||
test('normalizes string viewport values to numbers', () => {
|
||||
const result = getTransformPropsResult({
|
||||
viewportLongitude: '-122.4',
|
||||
viewportLatitude: '37.8',
|
||||
viewportZoom: '12',
|
||||
viewport_longitude: '-122.4',
|
||||
viewport_latitude: '37.8',
|
||||
viewport_zoom: '12',
|
||||
});
|
||||
expect(result.viewportLongitude).toBe(-122.4);
|
||||
expect(result.viewportLatitude).toBe(37.8);
|
||||
@@ -159,9 +162,9 @@ test('normalizes string viewport values to numbers', () => {
|
||||
|
||||
test('normalizes empty viewport values to undefined', () => {
|
||||
const result = getTransformPropsResult({
|
||||
viewportLongitude: '',
|
||||
viewportLatitude: '',
|
||||
viewportZoom: '',
|
||||
viewport_longitude: '',
|
||||
viewport_latitude: '',
|
||||
viewport_zoom: '',
|
||||
});
|
||||
expect(result.viewportLongitude).toBeUndefined();
|
||||
expect(result.viewportLatitude).toBeUndefined();
|
||||
@@ -170,9 +173,9 @@ test('normalizes empty viewport values to undefined', () => {
|
||||
|
||||
test('normalizes whitespace-only viewport values to undefined', () => {
|
||||
const result = getTransformPropsResult({
|
||||
viewportLongitude: ' ',
|
||||
viewportLatitude: '\t',
|
||||
viewportZoom: ' \n ',
|
||||
viewport_longitude: ' ',
|
||||
viewport_latitude: '\t',
|
||||
viewport_zoom: ' \n ',
|
||||
});
|
||||
expect(result.viewportLongitude).toBeUndefined();
|
||||
expect(result.viewportLatitude).toBeUndefined();
|
||||
@@ -180,31 +183,31 @@ test('normalizes whitespace-only viewport values to undefined', () => {
|
||||
});
|
||||
|
||||
test('normalizes string opacity to number', () => {
|
||||
const result = getTransformPropsResult({ globalOpacity: '0.5' });
|
||||
const result = getTransformPropsResult({ global_opacity: '0.5' });
|
||||
expect(result.globalOpacity).toBe(0.5);
|
||||
});
|
||||
|
||||
test('defaults empty opacity to 1', () => {
|
||||
const result = getTransformPropsResult({ globalOpacity: '' });
|
||||
const result = getTransformPropsResult({ global_opacity: '' });
|
||||
expect(result.globalOpacity).toBe(1);
|
||||
});
|
||||
|
||||
test('defaults whitespace-only opacity to 1', () => {
|
||||
const result = getTransformPropsResult({ globalOpacity: ' ' });
|
||||
const result = getTransformPropsResult({ global_opacity: ' ' });
|
||||
expect(result.globalOpacity).toBe(1);
|
||||
});
|
||||
|
||||
test('clamps opacity to [0, 1] range', () => {
|
||||
expect(getTransformPropsResult({ globalOpacity: 5 }).globalOpacity).toBe(1);
|
||||
expect(getTransformPropsResult({ globalOpacity: -1 }).globalOpacity).toBe(0);
|
||||
expect(getTransformPropsResult({ global_opacity: 5 }).globalOpacity).toBe(1);
|
||||
expect(getTransformPropsResult({ global_opacity: -1 }).globalOpacity).toBe(0);
|
||||
});
|
||||
|
||||
test('passes through numeric values unchanged', () => {
|
||||
const result = getTransformPropsResult({
|
||||
viewportLongitude: -122.4,
|
||||
viewportLatitude: 37.8,
|
||||
viewportZoom: 12,
|
||||
globalOpacity: 0.8,
|
||||
viewport_longitude: -122.4,
|
||||
viewport_latitude: 37.8,
|
||||
viewport_zoom: 12,
|
||||
global_opacity: 0.8,
|
||||
});
|
||||
expect(result.viewportLongitude).toBe(-122.4);
|
||||
expect(result.viewportLatitude).toBe(37.8);
|
||||
@@ -212,19 +215,18 @@ test('passes through numeric values unchanged', () => {
|
||||
expect(result.globalOpacity).toBe(0.8);
|
||||
});
|
||||
|
||||
test('calls onError and returns empty object for invalid color', () => {
|
||||
test('calls onError and falls back to black for invalid color', () => {
|
||||
const onError = jest.fn();
|
||||
const chartProps = new ChartProps({
|
||||
formData: { ...baseFormData, mapboxColor: 'invalid-color' },
|
||||
formData: { ...baseFormData, map_color: 'invalid-color' },
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: baseQueriesData,
|
||||
hooks: { onError },
|
||||
theme: supersetTheme,
|
||||
});
|
||||
const result = transformProps(chartProps);
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
"Color field must be of form 'rgb(%d, %d, %d)'",
|
||||
);
|
||||
expect(result).toEqual({});
|
||||
const result = transformProps(chartProps) as TransformPropsResult;
|
||||
expect(onError).toHaveBeenCalled();
|
||||
// Falls back to black instead of returning empty object
|
||||
expect(result.rgb).toEqual(['', '0', '0', '0']);
|
||||
});
|
||||
@@ -1,12 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Path Resolution: Override baseUrl to maintain correct path mappings from parent config
|
||||
// (e.g., "@apache-superset/core" -> "./packages/superset-core/src")
|
||||
"baseUrl": "../..",
|
||||
|
||||
// Directory Overrides: Parent config paths are relative to frontend root,
|
||||
// but packages need paths relative to their own directory
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"declarationDir": "lib"
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* 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
|
||||
@@ -16,10 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { hexToRGB } from '../../src/utils/colors';
|
||||
|
||||
describe('colors', () => {
|
||||
test('hexToRGB()', () => {
|
||||
expect(hexToRGB('#ffffff')).toEqual([255, 255, 255, 255]);
|
||||
});
|
||||
});
|
||||
declare module '*.png' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@superset-ui/legacy-preset-chart-deckgl",
|
||||
"version": "0.20.4",
|
||||
"description": "Superset Legacy Chart - deck.gl",
|
||||
"name": "@superset-ui/preset-chart-deckgl",
|
||||
"version": "1.0.0",
|
||||
"description": "Superset Chart Plugin - deck.gl (MapLibre)",
|
||||
"keywords": [
|
||||
"superset"
|
||||
],
|
||||
@@ -12,7 +12,7 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/apache/superset.git",
|
||||
"directory": "superset-frontend/packages/legacy-preset-chart-deckgl"
|
||||
"directory": "superset-frontend/plugins/preset-chart-deckgl"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": "Superset",
|
||||
@@ -29,14 +29,13 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "~9.2.5",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@deck.gl/react": "~9.2.11",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.6",
|
||||
"@luma.gl/shadertools": "~9.2.6",
|
||||
"@luma.gl/webgl": "~9.2.6",
|
||||
"@mapbox/tiny-sdf": "^2.0.7",
|
||||
"@mapbox/geojson-extent": "^1.0.1",
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"@types/d3-array": "^3.2.2",
|
||||
@@ -47,10 +46,12 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"handlebars": "^4.7.9",
|
||||
"lodash": "^4.18.1",
|
||||
"maplibre-gl": "^5.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"ngeohash": "^0.6.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"underscore": "^1.13.8",
|
||||
"react-map-gl": "^8.0.0",
|
||||
"underscore": "^1.13.7",
|
||||
"urijs": "^1.19.11",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
@@ -65,10 +66,14 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"mapbox-gl": "*",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-map-gl": "^6.1.19"
|
||||
"mapbox-gl": ">=1.0.0",
|
||||
"react": "^17.0.2 || ^19.0.0",
|
||||
"react-dom": "^17.0.2 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mapbox-gl": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import Legend from './components/Legend';
|
||||
import { hexToRGB } from './utils/colors';
|
||||
import { getMapboxApiKey } from './utils/mapbox';
|
||||
import sandboxedEval from './utils/sandbox';
|
||||
import fitViewport, { Viewport } from './utils/fitViewport';
|
||||
import {
|
||||
@@ -83,7 +84,6 @@ function getCategories(fd: QueryFormData, data: JsonObject[]) {
|
||||
export type CategoricalDeckGLContainerProps = {
|
||||
datasource: Datasource;
|
||||
formData: QueryFormData;
|
||||
mapboxApiKey: string;
|
||||
getPoints: (data: JsonObject[]) => Point[];
|
||||
height: number;
|
||||
width: number;
|
||||
@@ -155,7 +155,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
|
||||
switch (selectedColorScheme) {
|
||||
case COLOR_SCHEME_TYPES.fixed_color: {
|
||||
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 100 };
|
||||
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
||||
const colorArray = [color.r, color.g, color.b, color.a * 255];
|
||||
|
||||
return data.map(d => ({ ...d, color: colorArray }));
|
||||
@@ -166,7 +166,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 100,
|
||||
a: 1,
|
||||
};
|
||||
const colorArray = [
|
||||
fallbackColor.r,
|
||||
@@ -325,8 +325,15 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
viewport={viewport}
|
||||
layers={getLayers()}
|
||||
setControlValue={props.setControlValue}
|
||||
mapStyle={props.formData.mapbox_style}
|
||||
mapboxApiAccessToken={props.mapboxApiKey}
|
||||
mapStyle={
|
||||
props.formData.map_renderer === 'mapbox'
|
||||
? props.formData.mapbox_style
|
||||
: props.formData.maplibre_style
|
||||
}
|
||||
mapProvider={
|
||||
props.formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
|
||||
}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
/>
|
||||
@@ -1,7 +1,3 @@
|
||||
/* eslint-disable react/jsx-sort-default-props */
|
||||
/* eslint-disable react/sort-prop-types */
|
||||
/* eslint-disable react/jsx-handler-names */
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
@@ -30,32 +26,32 @@ import {
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
isValidElement,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { isEqual } from 'lodash';
|
||||
import { StaticMap } from 'react-map-gl';
|
||||
import DeckGL from '@deck.gl/react';
|
||||
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
|
||||
import { Map as MapboxMap } from 'react-map-gl/mapbox';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import { JsonObject, JsonValue, usePrevious } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Device } from '@luma.gl/core';
|
||||
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import DeckGLOverlayMapLibre from './components/DeckGLOverlayMapLibre';
|
||||
import DeckGLOverlayMapbox from './components/DeckGLOverlayMapbox';
|
||||
import Tooltip, { TooltipProps } from './components/Tooltip';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { Viewport } from './utils/fitViewport';
|
||||
import {
|
||||
MAPBOX_LAYER_PREFIX,
|
||||
OSM_LAYER_KEYWORDS,
|
||||
TILE_LAYER_PREFIX,
|
||||
buildTileLayer,
|
||||
} from './utils';
|
||||
|
||||
const TICK = 250; // milliseconds
|
||||
|
||||
const DEFAULT_MAP_STYLE =
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
|
||||
|
||||
export type DeckGLContainerProps = {
|
||||
viewport: Viewport;
|
||||
setControlValue?: (control: string, value: JsonValue) => void;
|
||||
mapStyle?: string;
|
||||
mapboxApiAccessToken: string;
|
||||
mapProvider?: 'maplibre' | 'mapbox';
|
||||
mapboxApiKey?: string;
|
||||
children?: ReactNode;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -69,14 +65,6 @@ export const DeckGLContainer = memo(
|
||||
const [lastUpdate, setLastUpdate] = useState<number | null>(null);
|
||||
const [viewState, setViewState] = useState(props.viewport);
|
||||
const prevViewport = usePrevious(props.viewport);
|
||||
const glContextRef = useRef<WebGL2RenderingContext | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
glContextRef.current?.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({ setTooltip }), []);
|
||||
|
||||
@@ -93,7 +81,7 @@ export const DeckGLContainer = memo(
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(tick, TICK);
|
||||
return clearInterval(timer);
|
||||
return () => clearInterval(timer);
|
||||
}, [tick]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,31 +90,12 @@ export const DeckGLContainer = memo(
|
||||
}
|
||||
}, [prevViewport, props.viewport]);
|
||||
|
||||
const onViewStateChange = useCallback(
|
||||
({ viewState }: { viewState: JsonObject }) => {
|
||||
setViewState(viewState as Viewport);
|
||||
setLastUpdate(Date.now());
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onMove = useCallback((evt: { viewState: JsonObject }) => {
|
||||
setViewState(evt.viewState as Viewport);
|
||||
setLastUpdate(Date.now());
|
||||
}, []);
|
||||
|
||||
const layers = useCallback(() => {
|
||||
if (
|
||||
(props.mapStyle?.startsWith(TILE_LAYER_PREFIX) ||
|
||||
OSM_LAYER_KEYWORDS.some((tilek: string) =>
|
||||
props.mapStyle?.includes(tilek),
|
||||
)) &&
|
||||
props.layers.some(
|
||||
l => typeof l !== 'function' && l?.id === 'tile-layer',
|
||||
) === false
|
||||
) {
|
||||
props.layers.unshift(
|
||||
buildTileLayer(
|
||||
(props.mapStyle ?? '').replace(TILE_LAYER_PREFIX, ''),
|
||||
'tile-layer',
|
||||
),
|
||||
);
|
||||
}
|
||||
// Support for layer factory
|
||||
if (props.layers.some(l => typeof l === 'function')) {
|
||||
return props.layers.map(l =>
|
||||
@@ -135,7 +104,7 @@ export const DeckGLContainer = memo(
|
||||
}
|
||||
|
||||
return props.layers as Layer[];
|
||||
}, [props.layers, props.mapStyle]);
|
||||
}, [props.layers]);
|
||||
|
||||
const isCustomTooltip = (content: ReactNode): boolean =>
|
||||
isValidElement(content) &&
|
||||
@@ -151,7 +120,35 @@ export const DeckGLContainer = memo(
|
||||
return <Tooltip tooltip={tooltipState} />;
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
const { children = null, height, width } = props;
|
||||
const isMapbox = props.mapProvider === 'mapbox';
|
||||
const mapStyle = props.mapStyle || DEFAULT_MAP_STYLE;
|
||||
|
||||
if (isMapbox && !props.mapboxApiKey) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
textAlign: 'center',
|
||||
color: theme.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMapbox && props.mapboxApiKey) {
|
||||
mapboxgl.accessToken = props.mapboxApiKey;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -162,28 +159,25 @@ export const DeckGLContainer = memo(
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DeckGL
|
||||
controller
|
||||
width={width}
|
||||
height={height}
|
||||
layers={layers()}
|
||||
viewState={viewState}
|
||||
onViewStateChange={onViewStateChange}
|
||||
onAfterRender={(context: {
|
||||
device: Device;
|
||||
gl: WebGL2RenderingContext;
|
||||
}) => {
|
||||
glContextRef.current = context.gl;
|
||||
}}
|
||||
>
|
||||
{props.mapStyle?.startsWith(MAPBOX_LAYER_PREFIX) && (
|
||||
<StaticMap
|
||||
preserveDrawingBuffer
|
||||
mapStyle={props.mapStyle || 'light'}
|
||||
mapboxApiAccessToken={props.mapboxApiAccessToken}
|
||||
/>
|
||||
)}
|
||||
</DeckGL>
|
||||
{isMapbox ? (
|
||||
<MapboxMap
|
||||
{...viewState}
|
||||
onMove={onMove}
|
||||
mapStyle={mapStyle}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<DeckGLOverlayMapbox layers={layers()} />
|
||||
</MapboxMap>
|
||||
) : (
|
||||
<MapLibreMap
|
||||
{...viewState}
|
||||
onMove={onMove}
|
||||
mapStyle={mapStyle}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<DeckGLOverlayMapLibre layers={layers()} />
|
||||
</MapLibreMap>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{renderTooltip(tooltip)}
|
||||
@@ -58,7 +58,7 @@ const baseMockProps = {
|
||||
viz_type: 'deck_multi',
|
||||
deck_slices: [1, 2],
|
||||
autozoom: false,
|
||||
mapbox_style: 'mapbox://styles/mapbox/light-v9',
|
||||
map_style: 'mapbox://styles/mapbox/light-v9',
|
||||
},
|
||||
payload: {
|
||||
data: {
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import { getExploreLongUrl } from '../utils/explore';
|
||||
import layerGenerators from '../layers';
|
||||
import fitViewport, { Viewport } from '../utils/fitViewport';
|
||||
import { getMapboxApiKey } from '../utils/mapbox';
|
||||
import { TooltipProps } from '../components/Tooltip';
|
||||
|
||||
import { getPoints as getPointsArc } from '../layers/Arc/Arc';
|
||||
@@ -377,7 +378,7 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
);
|
||||
|
||||
if (deckSlicesChanged || visibilityFilterChanged) {
|
||||
loadLayers(formData, payload, undefined);
|
||||
loadLayers(formData, payload, visibleDeckLayersFromRedux);
|
||||
}
|
||||
}, [
|
||||
loadLayers,
|
||||
@@ -387,7 +388,7 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
props,
|
||||
]);
|
||||
|
||||
const { payload, formData, setControlValue, height, width } = props;
|
||||
const { formData, setControlValue, height, width } = props;
|
||||
|
||||
const layers = useMemo(
|
||||
() =>
|
||||
@@ -401,10 +402,15 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
<MultiWrapper height={height} width={width}>
|
||||
<DeckGLContainerStyledWrapper
|
||||
ref={containerRef}
|
||||
mapboxApiAccessToken={payload.data.mapboxApiKey}
|
||||
viewport={viewport}
|
||||
layers={layers}
|
||||
mapStyle={formData.mapbox_style}
|
||||
mapStyle={
|
||||
formData.map_renderer === 'mapbox'
|
||||
? formData.mapbox_style
|
||||
: formData.maplibre_style
|
||||
}
|
||||
mapProvider={formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
setControlValue={setControlValue}
|
||||
onViewportChange={setViewport}
|
||||
height={height}
|
||||
@@ -18,7 +18,13 @@
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { validateNonEmpty } from '@superset-ui/core';
|
||||
import { viewport, mapboxStyle, autozoom } from '../utilities/Shared_DeckGL';
|
||||
import {
|
||||
viewport,
|
||||
mapboxStyle,
|
||||
maplibreStyle,
|
||||
mapProvider,
|
||||
autozoom,
|
||||
} from '../utilities/Shared_DeckGL';
|
||||
|
||||
export default {
|
||||
controlPanelSections: [
|
||||
@@ -26,7 +32,9 @@ export default {
|
||||
label: t('Map'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[mapProvider],
|
||||
[mapboxStyle],
|
||||
[maplibreStyle],
|
||||
[viewport],
|
||||
[autozoom],
|
||||
[
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 251 KiB |
@@ -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 { useControl } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import type { MapboxOverlayProps } from '@deck.gl/mapbox';
|
||||
|
||||
export default function DeckGLOverlayMapLibre(props: MapboxOverlayProps) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay(props));
|
||||
overlay.setProps(props);
|
||||
return null;
|
||||
}
|
||||
@@ -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 { useControl } from 'react-map-gl/mapbox';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import type { MapboxOverlayProps } from '@deck.gl/mapbox';
|
||||
|
||||
export default function DeckGLOverlayMapbox(props: MapboxOverlayProps) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay(props));
|
||||
overlay.setProps(props);
|
||||
return null;
|
||||
}
|
||||
@@ -113,8 +113,14 @@ const Legend = ({
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
onClick={() => toggleCategory(k)}
|
||||
onDoubleClick={() => showSingleCategory(k)}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
toggleCategory(k);
|
||||
}}
|
||||
onDoubleClick={e => {
|
||||
e.preventDefault();
|
||||
showSingleCategory(k);
|
||||
}}
|
||||
>
|
||||
<span style={style}>{icon}</span> {formatCategoryLabel(k)}
|
||||
</a>
|
||||
@@ -42,8 +42,8 @@ const StyledDiv = styled.div<{
|
||||
position: absolute;
|
||||
top: ${top}px;
|
||||
left: ${left}px;
|
||||
zIndex: 9;
|
||||
pointerEvents: none;
|
||||
z-index: 9;
|
||||
pointer-events: none;
|
||||
${
|
||||
variant === 'default'
|
||||
? `
|
||||
@@ -51,8 +51,8 @@ const StyledDiv = styled.div<{
|
||||
margin: ${theme.sizeUnit * 2}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
color: ${theme.colorText};
|
||||
maxWidth: 300px;
|
||||
fontSize: ${theme.fontSizeSM}px;
|
||||
max-width: 300px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
border: 1px solid ${theme.colorBorder};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
box-shadow: ${theme.boxShadowSecondary};
|
||||
@@ -19,6 +19,7 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { isEqual } from 'lodash';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import { getMapboxApiKey } from './utils/mapbox';
|
||||
import {
|
||||
Datasource,
|
||||
QueryFormData,
|
||||
@@ -182,16 +183,23 @@ export function createDeckGLComponent(
|
||||
}
|
||||
}, [computeLayers, prevFormData, prevFilterState, prevPayload, props]);
|
||||
|
||||
const { formData, payload, setControlValue, height, width } = props;
|
||||
const { formData, setControlValue, height, width } = props;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<DeckGLContainerStyledWrapper
|
||||
ref={containerRef}
|
||||
mapboxApiAccessToken={payload.data.mapboxApiKey}
|
||||
viewport={viewport}
|
||||
layers={layers}
|
||||
mapStyle={formData.mapbox_style}
|
||||
mapStyle={
|
||||
formData.map_renderer === 'mapbox'
|
||||
? formData.mapbox_style
|
||||
: formData.maplibre_style
|
||||
}
|
||||
mapProvider={
|
||||
formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
|
||||
}
|
||||
mapboxApiKey={getMapboxApiKey()}
|
||||
setControlValue={setControlValue}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -232,7 +240,6 @@ export function createCategoricalDeckGLComponent(
|
||||
<CategoricalDeckGLContainer
|
||||
datasource={datasource}
|
||||
formData={formData}
|
||||
mapboxApiKey={payload.data.mapboxApiKey}
|
||||
setControlValue={setControlValue}
|
||||
viewport={viewport}
|
||||
getLayer={getLayer}
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
legendPosition,
|
||||
viewport,
|
||||
mapboxStyle,
|
||||
maplibreStyle,
|
||||
mapProvider,
|
||||
tooltipContents,
|
||||
tooltipTemplate,
|
||||
deckGLCategoricalColor,
|
||||
@@ -86,7 +88,12 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
{
|
||||
label: t('Map'),
|
||||
controlSetRows: [[mapboxStyle], [autozoom, viewport]],
|
||||
controlSetRows: [
|
||||
[mapProvider],
|
||||
[mapboxStyle],
|
||||
[maplibreStyle],
|
||||
[autozoom, viewport],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Arc'),
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
@@ -20,7 +20,7 @@
|
||||
/* eslint-disable sort-keys */
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { ArcChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
import { ArcChartPlugin } from '@superset-ui/preset-chart-deckgl';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import payload from './payload';
|
||||
import { dummyDatasource } from '@storybook-shared';
|
||||
@@ -28,7 +28,7 @@ import { dummyDatasource } from '@storybook-shared';
|
||||
new ArcChartPlugin().configure({ key: 'deck_arc' }).register();
|
||||
|
||||
export default {
|
||||
title: 'Legacy Chart Plugins/legacy-preset-chart-deckgl/ArcChartPlugin',
|
||||
title: 'Chart Plugins/preset-chart-deckgl/ArcChartPlugin',
|
||||
decorators: [withResizableChartDemo],
|
||||
args: {
|
||||
strokeWidth: 1,
|
||||
@@ -90,7 +90,8 @@ export const ArcChartViz = ({
|
||||
row_limit: 5000,
|
||||
filter_nulls: true,
|
||||
adhoc_filters: [],
|
||||
mapbox_style: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
map_style:
|
||||
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
viewport: {
|
||||
altitude: 1.5,
|
||||
bearing: 8.546256357301871,
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
jsOnclickHref,
|
||||
jsTooltip,
|
||||
mapboxStyle,
|
||||
maplibreStyle,
|
||||
mapProvider,
|
||||
spatial,
|
||||
viewport,
|
||||
tooltipContents,
|
||||
@@ -55,7 +57,9 @@ const config: ControlPanelConfig = {
|
||||
label: t('Map'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[mapProvider],
|
||||
[mapboxStyle],
|
||||
[maplibreStyle],
|
||||
[autozoom, viewport],
|
||||
[
|
||||
{
|
||||
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 203 KiB |
@@ -24,9 +24,10 @@ import {
|
||||
computeGeoJsonIconOptionsFromFormData,
|
||||
} from './Geojson';
|
||||
|
||||
jest.mock('@deck.gl/react', () => ({
|
||||
jest.mock('react-map-gl/maplibre', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
Map: () => null,
|
||||
useControl: () => null,
|
||||
}));
|
||||
|
||||
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
|
||||
@@ -123,6 +123,8 @@ function setTooltipContent(o: JsonObject) {
|
||||
}
|
||||
|
||||
const getFillColor = (feature: JsonObject, filterStateValue: unknown[]) => {
|
||||
const baseColor = feature?.properties?.fillColor;
|
||||
|
||||
if (filterStateValue) {
|
||||
if (
|
||||
JSON.stringify(feature.geometry.coordinates) ===
|
||||
@@ -131,11 +133,14 @@ const getFillColor = (feature: JsonObject, filterStateValue: unknown[]) => {
|
||||
return HIGHLIGHT_COLOR_ARRAY;
|
||||
}
|
||||
|
||||
const fillColor = feature?.properties?.fillColor;
|
||||
fillColor[3] = 125;
|
||||
return fillColor;
|
||||
if (Array.isArray(baseColor) && baseColor.length >= 4) {
|
||||
return [baseColor[0], baseColor[1], baseColor[2], 125];
|
||||
}
|
||||
|
||||
return baseColor ?? HIGHLIGHT_COLOR_ARRAY;
|
||||
}
|
||||
return feature?.properties?.fillColor;
|
||||
|
||||
return baseColor;
|
||||
};
|
||||
const getLineColor = (feature: JsonObject) => feature?.properties?.strokeColor;
|
||||
|
||||
@@ -410,10 +415,9 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
|
||||
return (
|
||||
<DeckGLContainerStyledWrapper
|
||||
ref={containerRef}
|
||||
mapboxApiAccessToken={payload.data.mapboxApiKey}
|
||||
viewport={viewport}
|
||||
layers={[layer]}
|
||||
mapStyle={formData.mapbox_style}
|
||||
mapStyle={formData.map_style}
|
||||
setControlValue={setControlValue}
|
||||
height={height}
|
||||
width={width}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
buildQueryContext,
|
||||
ensureIsArray,
|
||||
QueryFormColumn,
|
||||
QueryObject,
|
||||
QueryObjectFilterClause,
|
||||
SqlaFormData,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
addJsColumnsToColumns,
|
||||
addTooltipColumnsToQuery,
|
||||
} from '../buildQueryUtils';
|
||||
|
||||
export interface DeckGeoJsonFormData extends SqlaFormData {
|
||||
geojson?: string;
|
||||
filter_nulls?: boolean;
|
||||
js_columns?: string[];
|
||||
tooltip_contents?: unknown[];
|
||||
}
|
||||
|
||||
export default function buildQuery(formData: DeckGeoJsonFormData) {
|
||||
const {
|
||||
geojson,
|
||||
filter_nulls = true,
|
||||
js_columns,
|
||||
tooltip_contents,
|
||||
} = formData;
|
||||
|
||||
if (!geojson) {
|
||||
throw new Error('GeoJSON column is required for GeoJSON charts');
|
||||
}
|
||||
|
||||
return buildQueryContext(formData, (baseQueryObject: QueryObject) => {
|
||||
let columns: QueryFormColumn[] = [
|
||||
...ensureIsArray(baseQueryObject.columns || []),
|
||||
geojson,
|
||||
];
|
||||
|
||||
// Add js_columns
|
||||
const columnStrings = columns.map(col =>
|
||||
typeof col === 'string' ? col : col.label || col.sqlExpression || '',
|
||||
);
|
||||
const withJsColumns = addJsColumnsToColumns(columnStrings, js_columns);
|
||||
columns = withJsColumns as QueryFormColumn[];
|
||||
|
||||
// Add tooltip columns
|
||||
columns = addTooltipColumnsToQuery(columns, tooltip_contents);
|
||||
|
||||
// Add null filter for geojson column
|
||||
const filters: QueryObjectFilterClause[] = ensureIsArray(
|
||||
baseQueryObject.filters || [],
|
||||
);
|
||||
if (filter_nulls) {
|
||||
filters.push({ col: geojson, op: 'IS NOT NULL' });
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...baseQueryObject,
|
||||
columns,
|
||||
metrics: [],
|
||||
groupby: [],
|
||||
filters,
|
||||
is_timeseries: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
extruded,
|
||||
viewport,
|
||||
mapboxStyle,
|
||||
maplibreStyle,
|
||||
mapProvider,
|
||||
autozoom,
|
||||
lineWidth,
|
||||
tooltipContents,
|
||||
@@ -79,7 +81,12 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
{
|
||||
label: t('Map'),
|
||||
controlSetRows: [[mapboxStyle, viewport], [autozoom]],
|
||||
controlSetRows: [
|
||||
[mapProvider],
|
||||
[mapboxStyle],
|
||||
[maplibreStyle],
|
||||
[viewport, autozoom],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('GeoJson Settings'),
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |