feat(plugin): add plugin-chart-cartodiagram (#25869)

Co-authored-by: Jakob Miksch <jakob@meggsimum.de>
This commit is contained in:
Jan Suleiman
2025-01-06 17:58:03 +01:00
committed by GitHub
parent 5484db34f9
commit a986a61b5f
72 changed files with 8434 additions and 193 deletions

View File

@@ -253,6 +253,9 @@ You can get current nonce value by calling jinja macro `csp_nonce()`.
connect-src 'self' https://api.mapbox.com https://events.mapbox.com
```
- Cartodiagram charts request map data (image and json) from external resources that can be edited by users,
and therefore either require a list of allowed domains to request from or a wildcard (`'*'`) for `img-src` and `connect-src`.
* Other CSP directives default to `'self'` to limit content to the same origin as the Superset server.
In order to adjust provided CSP configuration to your needs, follow the instructions and examples provided in

View File

@@ -56,7 +56,7 @@ module.exports = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
snapshotSerializers: ['@emotion/jest/enzyme-serializer'],
transformIgnorePatterns: [
'node_modules/(?!d3-(interpolate|color|time)|remark-gfm|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms)',
'node_modules/(?!d3-(interpolate|color|time)|remark-gfm|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol)',
],
globals: {
__DEV__: true,

File diff suppressed because it is too large Load Diff

View File

@@ -111,6 +111,7 @@
"@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/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
@@ -139,11 +140,17 @@
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.2.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
"fuse.js": "^7.0.0",
"geolib": "^2.0.24",
"geostyler": "^12.0.2",
"geostyler-data": "^1.0.0",
"geostyler-openlayers-parser": "^4.3.0",
"geostyler-style": "^7.5.0",
"geostyler-wfs-parser": "^2.0.3",
"googleapis": "^130.0.0",
"html-webpack-plugin": "^5.3.2",
"immer": "^10.1.1",
@@ -164,6 +171,7 @@
"mousetrap": "^1.6.5",
"mustache": "^4.2.0",
"nanoid": "^5.0.9",
"ol": "^7.5.2",
"polished": "^4.3.1",
"prop-types": "^15.8.1",
"query-string": "^6.13.7",

View File

@@ -33,6 +33,7 @@ export * from './components/Dropdown';
export * from './components/Menu';
export * from './components/MetricOption';
export * from './components/Tooltip';
export { default as ControlHeader } from './components/ControlHeader';
export * from './shared-controls';
export * from './types';

View File

@@ -26,6 +26,7 @@ export enum VizType {
Bubble = 'bubble_v2',
Bullet = 'bullet',
Calendar = 'cal_heatmap',
Cartodiagram = 'cartodiagram',
Chord = 'chord',
Compare = 'compare',
CountryMap = 'country_map',

View File

@@ -31,6 +31,7 @@
"@mapbox/geojson-extent": "^1.0.1",
"@math.gl/web-mercator": "^4.1.0",
"@types/d3-array": "^2.0.0",
"@types/geojson": "^7946.0.15",
"bootstrap-slider": "^11.0.2",
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",

View File

@@ -0,0 +1,67 @@
<!--
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/plugin-chart-cartodiagram
This plugin allows visualizing charts on a map. To do so, the plugin makes use of existing charts and renders them on the
provided locations.
Configuring the charts: Charts will be configured in their respective editors. So all configuration options of any chart are supported.
Configuring the map: For the map, an arbitrary number of background layers (WMS, WFS, XYZ), the initial map extent, the chart background color and border radius, as well as the chart size (per zoom level) can be configured.
### Usage
The plugin is configured in `superset-frontend/src/visualizations/presets/MainPreset.js`.
```js
import { CartodiagramPlugin } from '@superset-ui/plugin-chart-cartodiagram';
new CartodiagramPlugin().configure({ key: 'cartodiagram' }).register();
```
Default layers can be added to the constructor. These layers will be added to each chart by default (but can be removed by editors). See also `./src/types.ts` for the definitions of types `WmsLayerConf`, `WfsLayerConf` and `XyzLayerConf`.
Example for an XYZ default layer:
```js
import { CartodiagramPlugin } from '@superset-ui/plugin-chart-cartodiagram';
const opts = {
defaultLayers: [
{
type: 'XYZ',
url: 'example.com/path/to/xyz/layer',
title: 'my default layer title',
attribution: 'my default layer attribution',
},
],
};
new CartodiagramPlugin(opts).configure({ key: 'cartodiagram' }).register();
```
Please note that by default, Superset rejects requests to third-party domains. If you want to include
layers from those, you have to adjust the CSP settings. See also docs/docs/security/security.mdx.
### Geometry Column
The plugin requires the selection of a geometry column for a dataset.
This is expected to be a GeoJSON-Point-Geometry string in WGS 84/Pseudo-Mercator (EPSG:3857). Other formats and projections
will be supported in the future.

View File

@@ -0,0 +1,51 @@
{
"name": "@superset-ui/plugin-chart-cartodiagram",
"version": "0.0.1",
"description": "An OpenLayers map that displays charts for single features.",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"repository": {
"type": "git",
"url": "git+https://github.com/apache-superset/superset-ui.git"
},
"keywords": [
"superset"
],
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/apache-superset/superset-ui/issues"
},
"homepage": "https://github.com/apache-superset/superset-ui#readme",
"contributors": [
"terrestris GmbH & Co. KG <info@terrestris.de> (https://www.terrestris.de)",
"meggsimum - Büro für Geoinformatik <info@meggsimum.de> (https://meggsimum.de)"
],
"publishConfig": {
"access": "public"
},
"dependencies": {
"@types/geojson": "^7946.0.10",
"geojson": "^0.5.0",
"lodash": "^4.17.21"
},
"peerDependencies": {
"@ant-design/icons": "^5.0.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"antd": "^4.10.3",
"geostyler": "^12.0.0",
"geostyler-data": "^1.0.0",
"geostyler-openlayers-parser": "^4.0.0",
"geostyler-style": "^7.2.0",
"geostyler-wfs-parser": "^2.0.0",
"ol": "^7.1.0",
"polished": "*",
"react": "^16.13.1",
"react-dom": "^16.13.0"
}
}

View File

@@ -0,0 +1,59 @@
/**
* 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 { createRef, useState } from 'react';
import { styled, useTheme } from '@superset-ui/core';
import OlMap from 'ol/Map';
import {
CartodiagramPluginProps,
CartodiagramPluginStylesProps,
} from './types';
import OlChartMap from './components/OlChartMap';
import 'ol/ol.css';
// The following Styles component is a <div> element, which has been styled using Emotion
// For docs, visit https://emotion.sh/docs/styled
// Theming variables are provided for your use via a ThemeProvider
// imported from @superset-ui/core. For variables available, please visit
// https://github.com/apache-superset/superset-ui/blob/master/packages/superset-ui-core/src/style/index.ts
const Styles = styled.div<CartodiagramPluginStylesProps>`
height: ${({ height }) => height}px;
width: ${({ width }) => width}px;
`;
export default function CartodiagramPlugin(props: CartodiagramPluginProps) {
const { height, width } = props;
const theme = useTheme();
const rootElem = createRef<HTMLDivElement>();
const [mapId] = useState(
`cartodiagram-plugin-${Math.floor(Math.random() * 1000000)}`,
);
const [olMap] = useState(new OlMap({}));
return (
<Styles ref={rootElem} height={height} width={width} theme={theme}>
<OlChartMap mapId={mapId} olMap={olMap} {...props} />
</Styles>
);
}

View File

@@ -0,0 +1,282 @@
/**
* 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 Layer from 'ol/layer/Layer';
import { FrameState } from 'ol/Map';
import { apply as applyTransform } from 'ol/transform';
import ReactDOM from 'react-dom';
import { SupersetTheme } from '@superset-ui/core';
import { ChartConfig, ChartLayerOptions, ChartSizeValues } from '../types';
import { createChartComponent } from '../util/chartUtil';
import { getProjectedCoordinateFromPointGeoJson } from '../util/geometryUtil';
import Loader from '../images/loading.gif';
/**
* Custom OpenLayers layer that displays charts on given locations.
*/
export class ChartLayer extends Layer {
charts: any[] = [];
chartConfigs: ChartConfig = {
type: 'FeatureCollection',
features: [],
};
chartSizeValues: ChartSizeValues = {};
chartVizType: string;
div: HTMLDivElement;
loadingMask: HTMLDivElement;
chartBackgroundCssColor = '';
chartBackgroundBorderRadius = 0;
theme: SupersetTheme;
/**
* Create a ChartLayer.
*
* @param {ChartLayerOptions} options The options to create a ChartLayer
* @param {ChartHtmlElement[]} options.charts An array with the chart objects containing the HTML element and the coordinate
* @param {ChartConfig} options.chartConfigs The chart configuration for the charts
* @param {ChartSizeValues} options.chartSizeValues The values for the chart sizes
* @param {String} options.chartVizType The viztype of the charts
* @param {String} options.chartBackgroundCssColor The color of the additionally added chart background
* @param {Number} options.chartBackgroundBorderRadius The border radius in percent of the additionally added chart background
* @param {Function} options.onMouseOver The handler function to execute when the mouse entering a HTML element
* @param {Function} options.onMouseOut The handler function to execute when the mouse leaves a HTML element
* @param {SupersetTheme} options.theme The superset theme
*/
constructor(options: ChartLayerOptions) {
super(options);
this.chartVizType = options.chartVizType;
if (options.chartConfigs) {
this.chartConfigs = options.chartConfigs;
}
if (options.chartSizeValues) {
this.chartSizeValues = options.chartSizeValues;
}
if (options.chartBackgroundCssColor) {
this.chartBackgroundCssColor = options.chartBackgroundCssColor;
}
if (options.chartBackgroundBorderRadius) {
this.chartBackgroundBorderRadius = options.chartBackgroundBorderRadius;
}
if (options.theme) {
this.theme = options.theme;
}
const spinner = document.createElement('img');
spinner.src = Loader;
spinner.style.position = 'relative';
spinner.style.width = '50px';
spinner.style.top = '50%';
spinner.style.left = '50%';
spinner.style.transform = 'translate(-50%, -50%)';
this.loadingMask = document.createElement('div');
this.loadingMask.style.position = 'relative';
this.loadingMask.style.height = '100%';
this.loadingMask.appendChild(spinner);
this.div = document.createElement('div');
// TODO: consider creating an OpenLayers event
if (options.onMouseOver) {
this.div.onmouseover = options.onMouseOver;
}
// TODO: consider creating an OpenLayers event
if (options.onMouseOut) {
this.div.onmouseout = options.onMouseOut;
}
}
setChartConfig(chartConfigs: ChartConfig, silent = false) {
this.chartConfigs = chartConfigs;
if (!silent) {
this.changed();
}
}
setChartVizType(chartVizType: string, silent = false) {
this.chartVizType = chartVizType;
if (!silent) {
this.changed();
}
}
setChartSizeValues(chartSizeValues: ChartSizeValues, silent = false) {
this.chartSizeValues = chartSizeValues;
if (!silent) {
this.changed();
}
}
setChartBackgroundCssColor(chartBackgroundCssColor: string, silent = false) {
this.chartBackgroundCssColor = chartBackgroundCssColor;
if (!silent) {
this.changed();
}
}
setChartBackgroundBorderRadius(
chartBackgroundBorderRadius: number,
silent = false,
) {
this.chartBackgroundBorderRadius = chartBackgroundBorderRadius;
if (!silent) {
this.changed();
}
}
/**
* Unmount and remove all created chart elements from the DOM.
*/
removeAllChartElements() {
this.charts.forEach(chart => {
ReactDOM.unmountComponentAtNode(chart.htmlElement);
chart.htmlElement.remove();
});
this.charts = [];
}
createCharts(zoom: number) {
const charts = this.chartConfigs.features.map(feature => {
const container = document.createElement('div');
let chartWidth = 0;
let chartHeight = 0;
if (this.chartSizeValues[zoom]) {
chartWidth = this.chartSizeValues[zoom].width;
chartHeight = this.chartSizeValues[zoom].height;
}
const chartComponent = createChartComponent(
this.chartVizType,
feature,
chartWidth,
chartHeight,
this.theme,
);
ReactDOM.render(chartComponent, container);
return {
htmlElement: container,
coordinate: getProjectedCoordinateFromPointGeoJson(feature.geometry),
width: chartWidth,
height: chartHeight,
feature,
};
});
this.charts = charts;
}
updateCharts(zoom: number) {
const charts = this.charts.map(chart => {
let chartWidth = 0;
let chartHeight = 0;
if (this.chartSizeValues[zoom]) {
chartWidth = this.chartSizeValues[zoom].width;
chartHeight = this.chartSizeValues[zoom].height;
}
// only rerender chart if size changes
if (chartWidth === chart.width && chartHeight === chart.height) {
return chart;
}
const chartComponent = createChartComponent(
this.chartVizType,
chart.feature,
chartWidth,
chartHeight,
this.theme,
);
ReactDOM.render(chartComponent, chart.htmlElement);
return {
...chart,
width: chartWidth,
height: chartHeight,
};
});
this.charts = charts;
}
render(frameState: FrameState | null) {
if (!frameState) {
return this.div;
}
const { viewState } = frameState;
const currentZoom = Math.round(viewState.zoom);
// nextResolution is only defined while an animation
// is in action. For this time we show a loading mask
// to keep the amount of chart rerenderings as low as possible.
if (viewState.nextResolution) {
return this.loadingMask;
}
if (this.charts.length === 0) {
this.createCharts(currentZoom);
} else {
this.updateCharts(currentZoom);
}
this.charts.forEach(chartObject => {
const { htmlElement, coordinate, width, height } = chartObject;
// clone, because applyTransform modifies in place
const coordCopy = [...coordinate];
const [x, y] = applyTransform(
frameState.coordinateToPixelTransform,
coordCopy,
);
// left and top are corrected to place the center of the chart to its location
htmlElement.style.left = `${x - width / 2}px`;
htmlElement.style.top = `${y - height / 2}px`;
htmlElement.style.position = 'absolute';
htmlElement.style['background-color' as any] =
this.chartBackgroundCssColor;
htmlElement.style['border-radius' as any] =
`${this.chartBackgroundBorderRadius}%`;
});
// TODO should we always replace the html elements or is there a better way?
const htmlElements = this.charts.map(c => c.htmlElement);
this.div.replaceChildren(...htmlElements);
return this.div;
}
}

View File

@@ -0,0 +1,53 @@
/**
* 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 { getChartComponentRegistry, ThemeProvider } from '@superset-ui/core';
import { FC, useEffect, useState } from 'react';
import { ChartWrapperProps } from '../types';
export const ChartWrapper: FC<ChartWrapperProps> = ({
vizType,
theme,
height,
width,
chartConfig,
}) => {
const [Chart, setChart] = useState<any>();
const getChartFromRegistry = async (vizType: string) => {
const registry = getChartComponentRegistry();
const c = await registry.getAsPromise(vizType);
setChart(() => c);
};
useEffect(() => {
getChartFromRegistry(vizType);
}, [vizType]);
return (
<ThemeProvider theme={theme}>
{Chart === undefined ? (
<></>
) : (
<Chart {...chartConfig.properties} height={height} width={width} />
)}
</ThemeProvider>
);
};
export default ChartWrapper;

View File

@@ -0,0 +1,409 @@
/**
* 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 { useEffect, useState } from 'react';
import Point from 'ol/geom/Point';
import { View } from 'ol';
import BaseEvent from 'ol/events/Event';
import { unByKey } from 'ol/Observable';
import { toLonLat } from 'ol/proj';
import { debounce } from 'lodash';
import { fitMapToCharts } from '../util/mapUtil';
import { ChartLayer } from './ChartLayer';
import { createLayer } from '../util/layerUtil';
import {
ChartConfig,
LayerConf,
MapViewConfigs,
OlChartMapProps,
} from '../types';
import { isChartConfigEqual } from '../util/chartUtil';
/** The name to reference the chart layer */
const CHART_LAYER_NAME = 'openlayers-chart-layer';
export const OlChartMap = (props: OlChartMapProps) => {
const {
height,
width,
mapId,
olMap,
chartConfigs,
chartSize,
chartVizType,
layerConfigs,
mapView,
chartBackgroundColor,
chartBackgroundBorderRadius,
setControlValue,
theme,
} = props;
const [currentChartConfigs, setCurrentChartConfigs] =
useState<ChartConfig>(chartConfigs);
const [currentMapView, setCurrentMapView] = useState<MapViewConfigs>(mapView);
/**
* Add map to correct DOM element.
*/
useEffect(() => {
olMap.setTarget(mapId);
}, [olMap, mapId]);
/**
* Update map size if size of parent container changes.
*/
useEffect(() => {
olMap.updateSize();
}, [olMap, width, height]);
/**
* The prop chartConfigs will always be created on the fly,
* therefore the shallow comparison of the effect hooks will
* always trigger. In this hook, we make a 'deep comparison'
* between the incoming prop and the state. Only if the objects
* differ will we set the state to the new object. All other
* effect hooks that depend on chartConfigs should now depend
* on currentChartConfigs instead.
*/
useEffect(() => {
setCurrentChartConfigs(oldCurrentChartConfigs => {
if (isChartConfigEqual(chartConfigs, oldCurrentChartConfigs)) {
return oldCurrentChartConfigs;
}
return chartConfigs;
});
}, [chartConfigs]);
/**
* The prop mapView will always be created on the fly,
* therefore the shallow comparison of the effect hooks will
* always trigger. In this hook, we compare only those props
* that might be changed from outside of the component, i.e the
* fixed properties and the mode. Only if these values differ will
* we set the state to the new object. All other effect hooks that
* depend on mapView should now depend on currentMapView instead.
*/
useEffect(() => {
setCurrentMapView(oldCurrentMapView => {
const sameFixedZoom = oldCurrentMapView.fixedZoom === mapView.fixedZoom;
const sameFixedLon =
oldCurrentMapView.fixedLongitude === mapView.fixedLongitude;
const sameFixedLat =
oldCurrentMapView.fixedLatitude === mapView.fixedLatitude;
const sameMode = oldCurrentMapView.mode === mapView.mode;
if (sameFixedZoom && sameFixedLon && sameFixedLat && sameMode) {
return oldCurrentMapView;
}
return mapView;
});
}, [mapView]);
/**
* Set initial map extent.
*/
useEffect(() => {
const view = olMap.getView();
const { mode, fixedLatitude, fixedLongitude, fixedZoom } = mapView;
switch (mode) {
case 'CUSTOM': {
const fixedCenter = new Point([fixedLongitude, fixedLatitude]);
fixedCenter.transform('EPSG:4326', 'EPSG:3857'); // in-place
view.setZoom(fixedZoom);
view.setCenter(fixedCenter.getCoordinates());
break;
}
default: {
fitMapToCharts(olMap, chartConfigs);
const zoom = view.getZoom();
const centerCoord = view.getCenter();
if (!centerCoord) return;
const centerPoint = new Point(centerCoord);
centerPoint.transform('EPSG:3857', 'EPSG:4326'); // in-place
const [longitude, latitude] = centerPoint.getCoordinates();
setControlValue('map_view', {
...mapView,
zoom,
longitude,
latitude,
fixedLatitude: latitude,
fixedLongitude: longitude,
fixedZoom: zoom,
});
break;
}
}
}, []);
/**
* Update non-chart layers
*/
useEffect(() => {
// clear existing layers
// We first filter the layers we want to remove,
// because removing items from an array during a loop can be erroneous.
const layersToRemove = olMap
.getLayers()
.getArray()
.filter(layer => !(layer instanceof ChartLayer));
layersToRemove.forEach(layer => {
olMap.removeLayer(layer);
});
const addLayers = async (configs: LayerConf[]) => {
// Loop through layer configs, create layers and add them to map.
// The first layer in the list will be the upmost layer on the map.
// With insertAt(0) we ensure that the chart layer will always
// stay on top, though.
const createdLayersPromises = configs.map(createLayer);
const createdLayers = await Promise.allSettled(createdLayersPromises);
createdLayers.forEach((createdLayer, idx) => {
if (createdLayer.status === 'fulfilled' && createdLayer.value) {
olMap.getLayers().insertAt(0, createdLayer.value);
} else {
console.warn(`Layer could not be created: ${configs[idx]}`);
}
});
};
addLayers(layerConfigs);
}, [olMap, layerConfigs]);
/**
* Create listener on map movement
*/
useEffect(() => {
const { fixedLatitude, fixedLongitude, fixedZoom } = currentMapView;
const view = olMap.getView();
const onViewChange = (event: BaseEvent) => {
const targetView: View = event.target as unknown as View;
const center = targetView.getCenter();
const zoom = targetView.getZoom();
if (!center) {
return;
}
const [longitude, latitude] = toLonLat(center);
setControlValue('map_view', {
...currentMapView,
zoom,
longitude,
latitude,
fixedLatitude,
fixedLongitude,
fixedZoom,
});
};
// TODO: maybe replace with debounce from lodash
// timeout=100ms seems to work well, 1000ms has other side-effects
function debounce(func: Function, timeout = 100) {
let timer: number;
return function (this: any, ...args: any) {
clearTimeout(timer);
timer = window.setTimeout(() => func.apply(this, args), timeout);
};
}
const debouncedOnViewChange = debounce((event: BaseEvent) => {
onViewChange(event);
});
const listenerKey = view.on('change', debouncedOnViewChange);
// this is executed before the next render,
// here we cleanup the listener
return () => {
unByKey(listenerKey);
};
}, [olMap, setControlValue, currentMapView, currentChartConfigs]);
useEffect(() => {
if (currentMapView.mode === 'FIT_DATA') {
const layers = olMap.getLayers();
const chartLayer = layers
.getArray()
.find(layer => layer instanceof ChartLayer) as ChartLayer;
if (!chartLayer) {
return;
}
const extent = chartLayer.getExtent();
if (!extent) {
return;
}
const view = olMap.getView();
view.fit(extent, {
size: [250, 250],
});
}
}, [olMap, currentMapView.mode]);
/**
* Send updated zoom to chart config control.
*/
useEffect(() => {
const view = olMap.getView();
const onViewChange = (event: BaseEvent) => {
const targetView: View = event.target as unknown as View;
// ensure only zoom has changed
const zoom = targetView.getZoom();
// needed for TypeScript
if (!zoom) return;
// round zoom to full integer
const previousZoom = Math.round(chartSize.configs.zoom);
const newZoom = Math.round(zoom);
// if zoom has not changed, we return and do not update the controls
if (previousZoom === newZoom) return;
const updatedChartSizeConf = {
...chartSize,
configs: {
...chartSize.configs,
zoom: newZoom,
},
};
setControlValue('chart_size', updatedChartSizeConf);
};
const debouncedOnZoomChange = debounce((event: BaseEvent) => {
onViewChange(event);
}, 100);
const listenerKey = view.on('change:resolution', debouncedOnZoomChange);
// This is executed before the next render,
// here we cleanup our listener.
return () => {
unByKey(listenerKey);
};
}, [olMap, setControlValue, chartSize]);
/**
* Handle changes that trigger changes of charts. Also instantiate
* the chart layer, if it does not exist yet.
*/
useEffect(() => {
const layers = olMap.getLayers();
const chartLayer = layers
.getArray()
.find(layer => layer instanceof ChartLayer) as ChartLayer;
const { r, g, b, a } = chartBackgroundColor;
const cssColor = `rgba(${r}, ${g}, ${b}, ${a})`;
if (!chartLayer) {
layers.forEach(layer => {
if (!(layer instanceof ChartLayer)) {
return;
}
// remove all chart elements from dom.
layer.removeAllChartElements();
// delete previous chart layers
olMap.removeLayer(layer);
});
// prevent map interactions when mouse is over chart element
// inspired by https://gis.stackexchange.com/questions/303331
const deactivateInteractions = () => {
olMap.getInteractions().forEach(interaction => {
interaction.setActive(false);
});
};
const activateInteractions = () => {
olMap.getInteractions().forEach(interaction => {
interaction.setActive(true);
});
};
const newChartLayer = new ChartLayer({
name: CHART_LAYER_NAME,
chartConfigs: currentChartConfigs,
chartVizType,
chartSizeValues: chartSize.values,
chartBackgroundCssColor: cssColor,
chartBackgroundBorderRadius,
onMouseOver: deactivateInteractions,
onMouseOut: activateInteractions,
theme,
});
olMap.addLayer(newChartLayer);
} else {
let recreateCharts = false;
if (chartVizType !== chartLayer.chartVizType) {
chartLayer.setChartVizType(chartVizType, true);
recreateCharts = true;
}
if (!isChartConfigEqual(currentChartConfigs, chartLayer.chartConfigs)) {
chartLayer.setChartConfig(currentChartConfigs, true);
recreateCharts = true;
}
// Only the last setter triggers rerendering of charts
chartLayer.setChartBackgroundBorderRadius(
chartBackgroundBorderRadius,
true,
);
chartLayer.setChartBackgroundCssColor(cssColor, true);
chartLayer.setChartSizeValues(chartSize.values, true);
if (recreateCharts) {
chartLayer.removeAllChartElements();
}
chartLayer.changed();
}
}, [
olMap,
theme,
currentChartConfigs,
chartVizType,
chartSize.values,
chartBackgroundColor,
chartBackgroundBorderRadius,
]);
return (
<div
id={mapId}
style={{
height: `${height}px`,
width: `${width}px`,
}}
/>
);
};
export default OlChartMap;

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,20 @@
/**
* 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-next-line import/prefer-default-export
export { default as CartodiagramPlugin } from './plugin';

View File

@@ -0,0 +1,54 @@
/**
* 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 { QueryFormData, getChartBuildQueryRegistry } from '@superset-ui/core';
export default function buildQuery(formData: QueryFormData) {
const {
selected_chart: selectedChartString,
geom_column: geometryColumn,
extra_form_data: extraFormData,
} = formData;
const selectedChart = JSON.parse(selectedChartString);
const vizType = selectedChart.viz_type;
const chartFormData = JSON.parse(selectedChart.params);
// Pass extra_form_data to chartFormData so that
// dashboard filters will also be applied to the charts
// on the map.
chartFormData.extra_form_data = {
...chartFormData.extra_form_data,
...extraFormData,
};
// adapt groupby property to ensure geometry column always exists
// and is always at first position
let { groupby } = chartFormData;
if (!groupby) {
groupby = [];
}
// add geometry column at the first place
groupby?.unshift(geometryColumn);
chartFormData.groupby = groupby;
// TODO: find way to import correct type "InclusiveLoaderResult"
const buildQueryRegistry = getChartBuildQueryRegistry();
const chartQueryBuilder = buildQueryRegistry.get(vizType) as any;
const chartQuery = chartQueryBuilder(chartFormData);
return chartQuery;
}

View File

@@ -0,0 +1,193 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t, validateNonEmpty } from '@superset-ui/core';
import { ControlPanelConfig } from '@superset-ui/chart-controls';
import { selectedChartMutator } from '../util/controlPanelUtil';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '../util/zoomUtil';
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Configuration'),
expanded: true,
controlSetRows: [
[
{
name: 'selected_chart',
config: {
type: 'SelectAsyncControl',
mutator: selectedChartMutator,
multi: false,
label: t('Chart'),
validators: [validateNonEmpty],
description: t('Choose a chart for displaying on the map'),
placeholder: t('Select chart'),
onAsyncErrorMessage: t('Error while fetching charts'),
mapStateToProps: state => {
if (state?.datasource?.id) {
const datasourceId = state.datasource.id;
const query = {
columns: ['id', 'slice_name', 'params', 'viz_type'],
filters: [
{
col: 'datasource_id',
opr: 'eq',
value: datasourceId,
},
],
page: 0,
// TODO check why we only retrieve 100 items, even though there are more
page_size: 999,
};
const dataEndpoint = `/api/v1/chart/?q=${JSON.stringify(
query,
)}`;
return { dataEndpoint };
}
// could not extract datasource from map
return {};
},
},
},
],
[
{
name: 'geom_column',
config: {
type: 'SelectControl',
label: t('Geometry Column'),
renderTrigger: false,
description: t('The name of the geometry column'),
mapStateToProps: state => ({
choices: state.datasource?.columns.map(c => [
c.column_name,
c.column_name,
]),
}),
validators: [validateNonEmpty],
},
},
],
],
},
{
label: t('Map Options'),
expanded: true,
controlSetRows: [
[
{
name: 'map_view',
config: {
type: 'MapViewControl',
renderTrigger: true,
description: t(
'The extent of the map on application start. FIT DATA automatically sets the extent so that all data points are included in the viewport. CUSTOM allows users to define the extent manually.',
),
label: t('Extent'),
dontRefreshOnChange: true,
default: {
mode: 'FIT_DATA',
},
},
},
],
[
{
// name is referenced in 'index.ts' for setting default value
name: 'layer_configs',
config: {
type: 'LayerConfigsControl',
renderTrigger: true,
label: t('Layers'),
default: [],
description: t('The configuration for the map layers'),
},
},
],
],
},
{
label: t('Chart Options'),
expanded: true,
controlSetRows: [
[
{
name: 'chart_background_color',
config: {
label: t('Background Color'),
description: t('The background color of the charts.'),
type: 'ColorPickerControl',
default: { r: 255, g: 255, b: 255, a: 0.2 },
renderTrigger: true,
},
},
],
[
{
name: 'chart_background_border_radius',
config: {
label: t('Corner Radius'),
description: t('The corner radius of the chart background'),
type: 'SliderControl',
default: 10,
min: 0,
step: 1,
max: 100,
renderTrigger: true,
},
},
],
[
{
name: 'chart_size',
config: {
type: 'ZoomConfigControl',
// set this to true, if we are able to render it fast
renderTrigger: true,
default: {
type: 'FIXED',
configs: {
zoom: 6,
width: 100,
height: 100,
slope: 30,
exponent: 2,
},
// create an object with keys MIN_ZOOM_LEVEL - MAX_ZOOM_LEVEL
// that all contain the same initial value
values: {
...Array.from(
{ length: MAX_ZOOM_LEVEL - MIN_ZOOM_LEVEL + 1 },
() => ({ width: 100, height: 100 }),
),
},
},
label: t('Chart size'),
description: t('Configure the chart size for each zoom level'),
},
},
],
],
},
],
};
export default config;

View File

@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import thumbnail from '../images/thumbnail.png';
import example1 from '../images/example1.png';
import example2 from '../images/example2.png';
import { CartodiagramPluginConstructorOpts } from '../types';
import { getLayerConfig } from '../util/controlPanelUtil';
export default class CartodiagramPlugin extends ChartPlugin {
constructor(opts: CartodiagramPluginConstructorOpts) {
const metadata = new ChartMetadata({
description:
'Display charts on a map. For using this plugin, users first have to create any other chart that can then be placed on the map.',
name: t('Cartodiagram'),
thumbnail,
tags: [t('Geo'), t('2D'), t('Spatial'), t('Experimental')],
category: t('Map'),
exampleGallery: [
{ url: example1, caption: t('Pie charts on a map') },
{ url: example2, caption: t('Line charts on a map') },
],
});
if (opts.defaultLayers) {
const layerConfig = getLayerConfig(controlPanel);
// set defaults for layer config if found
if (layerConfig) {
layerConfig.config.default = opts.defaultLayers;
} else {
// eslint-disable-next-line no-console
console.warn(
'Cannot set defaultLayers. layerConfig not found in control panel. Please check if the path to layerConfig should be adjusted.',
);
}
}
super({
buildQuery,
controlPanel,
loadChart: () => import('../CartodiagramPlugin'),
metadata,
transformProps,
});
}
}

View File

@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, getChartTransformPropsRegistry } from '@superset-ui/core';
import {
getChartConfigs,
parseSelectedChart,
} from '../util/transformPropsUtil';
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, hooks, theme } = chartProps;
const {
geomColumn,
selectedChart: selectedChartString,
chartSize,
layerConfigs,
mapView,
chartBackgroundColor,
chartBackgroundBorderRadius,
} = formData;
const { setControlValue = () => {} } = hooks;
const selectedChart = parseSelectedChart(selectedChartString);
const transformPropsRegistry = getChartTransformPropsRegistry();
const chartTransformer = transformPropsRegistry.get(selectedChart.viz_type);
const chartConfigs = getChartConfigs(
selectedChart,
geomColumn,
chartProps,
chartTransformer,
);
return {
width,
height,
geomColumn,
selectedChart,
chartConfigs,
chartVizType: selectedChart.viz_type,
chartSize,
layerConfigs,
mapView,
chartBackgroundColor,
chartBackgroundBorderRadius,
setControlValue,
theme,
};
}

View File

@@ -0,0 +1,31 @@
/**
* 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 { LayerConf, WfsLayerConf, WmsLayerConf, XyzLayerConf } from './types';
export const isWmsLayerConf = (
layerConf: LayerConf,
): layerConf is WmsLayerConf => layerConf.type === 'WMS';
export const isWfsLayerConf = (
layerConf: LayerConf,
): layerConf is WfsLayerConf => layerConf.type === 'WFS';
export const isXyzLayerConf = (
layerConf: LayerConf,
): layerConf is XyzLayerConf => layerConf.type === 'XYZ';

View File

@@ -0,0 +1,210 @@
/**
* 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 {
DataRecord,
SupersetTheme,
TimeseriesDataRecord,
} from '@superset-ui/core';
import { RenderFunction } from 'ol/layer/Layer';
import { Extent } from 'ol/extent';
import Source from 'ol/source/Source';
import { Coordinate } from 'ol/coordinate';
import { Map } from 'ol';
import { Feature, FeatureCollection, Point } from 'geojson';
import { Style } from 'geostyler-style';
export interface CartodiagramPluginStylesProps {
height: number;
width: number;
theme: SupersetTheme;
}
// TODO find a way to reference props from other charts
export type ChartConfigProperties = any;
export type ChartConfigFeature = Feature<Point, ChartConfigProperties>;
export type ChartConfig = FeatureCollection<
ChartConfigFeature['geometry'],
ChartConfigFeature['properties']
>;
interface CartodiagramPluginCustomizeProps {
geomColumn: string;
selectedChart: string;
chartConfigs: ChartConfig;
chartSize: ZoomConfigs;
chartVizType: string;
layerConfigs: LayerConf[];
mapView: MapViewConfigs;
chartBackgroundColor: {
r: number;
g: number;
b: number;
a: number;
};
chartBackgroundBorderRadius: number;
setControlValue: Function;
}
export type CartodiagramPluginProps = CartodiagramPluginStylesProps &
CartodiagramPluginCustomizeProps & {
data: TimeseriesDataRecord[];
};
export interface OlChartMapProps extends CartodiagramPluginProps {
mapId: string;
olMap: Map;
}
export interface BaseLayerConf {
title: string;
url: string;
type: string;
attribution?: string;
}
export interface WfsLayerConf extends BaseLayerConf {
type: 'WFS';
typeName: string;
version: string;
maxFeatures?: number;
style?: Style;
}
export interface XyzLayerConf extends BaseLayerConf {
type: 'XYZ';
}
export interface WmsLayerConf extends BaseLayerConf {
type: 'WMS';
version: string;
layersParam: string;
}
export type LayerConf = WmsLayerConf | WfsLayerConf | XyzLayerConf;
export type EventHandlers = Record<string, { (props: any): void }>;
export type SelectedChartConfig = {
viz_type: string;
params: {
[key: string]: any;
};
};
export type LocationConfigMapping = {
[key: string]: DataRecord[];
};
export type MapViewConfigs = {
mode: 'FIT_DATA' | 'CUSTOM';
zoom: number;
latitude: number;
longitude: number;
fixedZoom: number;
fixedLatitude: number;
fixedLongitude: number;
};
export type ZoomConfigs = ZoomConfigsFixed | ZoomConfigsLinear | ZoomConfigsExp;
export type ChartSizeValues = {
[index: number]: { width: number; height: number };
};
export interface ZoomConfigsBase {
type: string;
configs: {
zoom: number;
width: number;
height: number;
slope?: number;
exponent?: number;
};
values: ChartSizeValues;
}
export interface ZoomConfigsFixed extends ZoomConfigsBase {
type: 'FIXED';
}
export interface ZoomConfigsLinear extends ZoomConfigsBase {
type: 'LINEAR';
configs: {
zoom: number;
width: number;
height: number;
slope: number;
exponent?: number;
};
}
export interface ZoomConfigsExp extends ZoomConfigsBase {
type: 'EXP';
configs: {
zoom: number;
width: number;
height: number;
slope?: number;
exponent: number;
};
}
export type ChartHtmlElement = {
htmlElement: HTMLDivElement;
coordinate: Coordinate;
width: number;
height: number;
};
export type ChartLayerOptions = {
chartSizeValues?: ChartSizeValues;
chartConfigs?: ChartConfig;
chartVizType: string;
onMouseOver?: (this: GlobalEventHandlers, ev: MouseEvent) => any | undefined;
onMouseOut?: (this: GlobalEventHandlers, ev: MouseEvent) => any | undefined;
[key: string]: any; // allow custom types like 'name'
// these properties are copied from OpenLayers
// TODO: consider extending the OpenLayers options type
className?: string | undefined;
opacity?: number | undefined;
visible?: boolean | undefined;
extent?: Extent | undefined;
zIndex?: number | undefined;
minResolution?: number | undefined;
maxResolution?: number | undefined;
minZoom?: number | undefined;
maxZoom?: number | undefined;
source?: Source | undefined;
map?: Map | null | undefined;
render?: RenderFunction | undefined;
properties?: { [x: string]: any } | undefined;
};
export type CartodiagramPluginConstructorOpts = {
defaultLayers?: LayerConf[];
};
export type ChartWrapperProps = {
vizType: string;
theme: SupersetTheme;
width: number;
height: number;
chartConfig: ChartConfigFeature;
};

View File

@@ -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 { SupersetTheme } from '@superset-ui/core';
import { ChartConfig, ChartConfigFeature } from '../types';
import ChartWrapper from '../components/ChartWrapper';
/**
* Create a chart component for a location.
*
* @param chartVizType The superset visualization type
* @param chartConfigs The chart configurations
* @param chartWidth The chart width
* @param chartHeight The chart height
* @param chartTheme The chart theme
* @returns The chart as React component
*/
export const createChartComponent = (
chartVizType: string,
chartConfig: ChartConfigFeature,
chartWidth: number,
chartHeight: number,
chartTheme: SupersetTheme,
) => (
<ChartWrapper
vizType={chartVizType}
chartConfig={chartConfig}
width={chartWidth}
height={chartHeight}
theme={chartTheme}
/>
);
/**
* Simplifies a chart configuration by removing
* non-serializable properties.
*
* @param config The chart configuration to simplify.
* @returns The simplified chart configuration.
*/
export const simplifyConfig = (config: ChartConfig) => {
const simplifiedConfig: ChartConfig = {
type: config.type,
features: config.features.map(f => ({
type: f.type,
geometry: f.geometry,
properties: Object.keys(f.properties)
.filter(k => k !== 'refs')
.reduce((prev, cur) => ({ ...prev, [cur]: f.properties[cur] }), {}),
})),
};
return simplifiedConfig;
};
/**
* Check if two chart configurations are equal (deep equality).
*
* @param configA The first chart config for comparison.
* @param configB The second chart config for comparison.
* @returns True, if configurations are equal. False otherwise.
*/
export const isChartConfigEqual = (
configA: ChartConfig,
configB: ChartConfig,
) => {
const simplifiedConfigA = simplifyConfig(configA);
const simplifiedConfigB = simplifyConfig(configB);
return (
JSON.stringify(simplifiedConfigA) === JSON.stringify(simplifiedConfigB)
);
};

View File

@@ -0,0 +1,128 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import { SelectValue } from 'antd/lib/select';
import { ControlPanelConfig } from '@superset-ui/chart-controls';
/**
* Get the layer configuration object from the control panel.
*
* @param controlPanel The control panel
* @returns The layer configuration object or undefined if not found
*/
export const getLayerConfig = (controlPanel: ControlPanelConfig) => {
let layerConfig: any;
controlPanel.controlPanelSections.forEach(section => {
if (!section) {
return;
}
const { controlSetRows } = section;
controlSetRows.forEach((row: any[]) => {
const configObject = row[0] as any;
if (configObject && configObject.name === 'layer_configs') {
layerConfig = configObject;
}
});
});
return layerConfig;
};
/**
* Mutates response of chart request into select options.
*
* If a currently selected value is not included in the response,
* it will be added explicitly, in order to prevent antd from creating
* a non-user-friendly select option.
*
* @param response Response json from resolved http request.
* @param value The currently selected value of the select input.
* @returns The list of options for the select input.
*/
export const selectedChartMutator = (
response: Record<string, any>,
value: SelectValue | undefined,
) => {
if (!response?.result) {
if (value && typeof value === 'string') {
return [
{
label: JSON.parse(value).slice_name,
value,
},
];
}
return [];
}
const data: Record<string, any> = [];
if (value && typeof value === 'string') {
const parsedValue = JSON.parse(value);
let itemFound = false;
response.result.forEach((config: any) => {
const configString = JSON.stringify(config);
const sameId = config.id === parsedValue.id;
const isUpdated = configString !== value;
const label = config.slice_name;
if (sameId) {
itemFound = true;
}
if (!sameId || !isUpdated) {
data.push({
value: configString,
label,
});
} else {
data.push({
value: configString,
label: (
<span>
<i>({t('updated')}) </i>
{label}
</span>
),
});
data.push({
value,
label,
});
}
});
if (!itemFound) {
data.push({
value,
label: parsedValue.slice_name,
});
}
} else {
response.result.forEach((config: any) => {
const configString = JSON.stringify(config);
const label = config.slice_name;
data.push({
value: configString,
label,
});
});
}
return data;
};

View File

@@ -0,0 +1,60 @@
/**
* 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.
*/
/**
* Util for geometry related operations.
*/
import GeoJSON from 'ol/format/GeoJSON';
import Feature from 'ol/Feature';
import { Point as OlPoint } from 'ol/geom';
import VectorSource from 'ol/source/Vector';
import { Point as GeoJsonPoint } from 'geojson';
/**
* Extracts the coordinate from a Point GeoJSON in the current map projection.
*
* @param geoJsonPoint The GeoJSON string for the point
*
* @returns The coordinate
*/
export const getProjectedCoordinateFromPointGeoJson = (
geoJsonPoint: GeoJsonPoint,
) => {
const geom: OlPoint = new GeoJSON().readGeometry(geoJsonPoint, {
// TODO: adapt to map projection
featureProjection: 'EPSG:3857',
}) as OlPoint;
return geom.getCoordinates();
};
/**
* Computes the extent for an array of features.
*
* @param features An Array of OpenLayers features
* @returns The OpenLayers extent or undefined
*/
export const getExtentFromFeatures = (features: Feature[]) => {
if (features.length === 0) {
return undefined;
}
const source = new VectorSource();
source.addFeatures(features);
return source.getExtent();
};

View File

@@ -0,0 +1,160 @@
/**
* 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.
*/
/**
* Util for layer related operations.
*/
import OlParser from 'geostyler-openlayers-parser';
import TileLayer from 'ol/layer/Tile';
import TileWMS from 'ol/source/TileWMS';
import { bbox as bboxStrategy } from 'ol/loadingstrategy';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import XyzSource from 'ol/source/XYZ';
import GeoJSON from 'ol/format/GeoJSON';
import { WmsLayerConf, WfsLayerConf, LayerConf, XyzLayerConf } from '../types';
import { isWfsLayerConf, isWmsLayerConf, isXyzLayerConf } from '../typeguards';
import { isVersionBelow } from './serviceUtil';
/**
* Create a WMS layer.
*
* @param wmsLayerConf The layer configuration
*
* @returns The created WMS layer
*/
export const createWmsLayer = (wmsLayerConf: WmsLayerConf) => {
const { url, layersParam, version, attribution } = wmsLayerConf;
return new TileLayer({
source: new TileWMS({
url,
params: {
LAYERS: layersParam,
VERSION: version,
},
attributions: attribution,
}),
});
};
/**
* Create a XYZ layer.
*
* @param xyzLayerConf The layer configuration
*
* @returns The created XYZ layer
*/
export const createXyzLayer = (xyzLayerConf: XyzLayerConf) => {
const { url, attribution } = xyzLayerConf;
return new TileLayer({
source: new XyzSource({
url,
attributions: attribution,
}),
});
};
/**
* Create a WFS layer.
*
* @param wfsLayerConf The layer configuration
*
* @returns The created WFS layer
*/
export const createWfsLayer = async (wfsLayerConf: WfsLayerConf) => {
const {
url,
typeName,
maxFeatures,
version = '1.1.0',
style,
attribution,
} = wfsLayerConf;
const wfsSource = new VectorSource({
format: new GeoJSON(),
attributions: attribution,
url: extent => {
const requestUrl = new URL(url);
const params = requestUrl.searchParams;
params.append('service', 'wfs');
params.append('request', 'GetFeature');
params.append('outputFormat', 'application/json');
// TODO: make CRS configurable or take it from Ol Map
params.append('srsName', 'EPSG:3857');
params.append('version', version);
let typeNameQuery = 'typeNames';
if (isVersionBelow(version, '2.0.0', 'WFS')) {
typeNameQuery = 'typeName';
}
params.append(typeNameQuery, typeName);
params.append('bbox', extent.join(','));
if (maxFeatures) {
let maxFeaturesQuery = 'count';
if (isVersionBelow(version, '2.0.0', 'WFS')) {
maxFeaturesQuery = 'maxFeatures';
}
params.append(maxFeaturesQuery, maxFeatures.toString());
}
return requestUrl.toString();
},
strategy: bboxStrategy,
});
let writeStyleResult;
if (style) {
const olParser = new OlParser();
writeStyleResult = await olParser.writeStyle(style);
if (writeStyleResult.errors) {
console.warn('Could not create ol-style', writeStyleResult.errors);
return undefined;
}
}
return new VectorLayer({
source: wfsSource,
// @ts-ignore
style: writeStyleResult?.output,
});
};
/**
* Create a layer instance with the provided configuration.
*
* @param layerConf The layer configuration
*
* @returns The created layer
*/
export const createLayer = async (layerConf: LayerConf) => {
let layer;
if (isWmsLayerConf(layerConf)) {
layer = createWmsLayer(layerConf);
} else if (isWfsLayerConf(layerConf)) {
layer = await createWfsLayer(layerConf);
} else if (isXyzLayerConf(layerConf)) {
layer = createXyzLayer(layerConf);
} else {
console.warn('Provided layerconfig is not recognized');
}
return layer;
};

View File

@@ -0,0 +1,52 @@
/**
* 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.
*/
/**
* Util for map related operations.
*/
import { Map } from 'ol';
import GeoJSON from 'ol/format/GeoJSON';
import { ChartConfig } from '../types';
import { getExtentFromFeatures } from './geometryUtil';
// default map extent of world if no features are found
// TODO: move to generic config file or plugin configuration
// TODO: adapt to CRS other than Web Mercator
const defaultExtent = [-16000000, -7279000, 20500000, 11000000];
/**
* Fits map to the spatial extent of provided charts.
*
* @param olMap The OpenLayers map
* @param chartConfigs The chart configuration
*/
export const fitMapToCharts = (olMap: Map, chartConfigs: ChartConfig) => {
const view = olMap.getView();
const features = new GeoJSON().readFeatures(chartConfigs, {
// TODO: adapt to map projection
featureProjection: 'EPSG:3857',
});
const extent = getExtentFromFeatures(features) || defaultExtent;
view.fit(extent, {
// tested for a desktop size monitor
size: [250, 250],
});
};

View File

@@ -0,0 +1,59 @@
/**
* 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.
*/
/**
* Get the available versions of WFS and WMS.
*
* @returns the versions
*/
export const getServiceVersions = () => ({
WMS: ['1.3.0', '1.1.1'],
WFS: ['2.0.2', '2.0.0', '1.1.0'],
});
/**
* Checks if a given version is below the comparer version.
*
* @param version The version to check.
* @param below The version to compare to.
* @param serviceType The service type.
* @returns True, if the version is below comparer version. False, otherwise.
*/
export const isVersionBelow = (
version: string,
below: string,
serviceType: 'WFS' | 'WMS',
) => {
const versions = getServiceVersions()[serviceType];
// versions is ordered from newest to oldest, so we invert the order
// to improve the readability of this function.
versions.reverse();
const versionIdx = versions.indexOf(version);
if (versionIdx === -1) {
// TODO: consider throwing an error instead
return false;
}
const belowIdx = versions.indexOf(below);
if (belowIdx === -1) {
// TODO: consider throwing an error instead
return false;
}
return versionIdx < belowIdx;
};

View File

@@ -0,0 +1,340 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
ChartProps,
convertKeysToCamelCase,
DataRecord,
} from '@superset-ui/core';
import { isObject } from 'lodash';
import {
LocationConfigMapping,
SelectedChartConfig,
ChartConfig,
ChartConfigFeature,
} from '../types';
const COLUMN_SEPARATOR = ', ';
/**
* Get the indices of columns where the title is a geojson.
*
* @param columns List of column names.
* @returns List of indices containing geojsonColumns.
*/
export const getGeojsonColumns = (columns: string[]) =>
columns.reduce((prev, current, idx) => {
let parsedColName;
try {
parsedColName = JSON.parse(current);
} catch {
parsedColName = undefined;
}
if (!parsedColName || !isObject(parsedColName)) {
return [...prev];
}
if (!('type' in parsedColName) || !('coordinates' in parsedColName)) {
return [...prev];
}
return [...prev, idx];
}, []);
/**
* Create a column name ignoring provided indices.
*
* @param columns List of column names.
* @param ignoreIdx List of indices to ignore.
* @returns Column name.
*/
export const createColumnName = (columns: string[], ignoreIdx: number[]) =>
columns.filter((l, idx) => !ignoreIdx.includes(idx)).join(COLUMN_SEPARATOR);
/**
* Group data by location for data providing a generic
* x-axis.
*
* @param data The data to group.
* @param params The data params.
* @returns Data grouped by location.
*/
export const groupByLocationGenericX = (
data: DataRecord[],
params: SelectedChartConfig['params'],
queryData: any,
) => {
const locations: LocationConfigMapping = {};
if (!data) {
return locations;
}
data.forEach(d => {
Object.keys(d)
.filter(k => k !== params.x_axis)
.forEach(k => {
const labelMap: string[] = queryData.label_map?.[k];
if (!labelMap) {
console.log(
'Cannot extract location from queryData. label_map not defined',
);
return;
}
const geojsonCols = getGeojsonColumns(labelMap);
if (geojsonCols.length > 1) {
// TODO what should we do, if there is more than one geom column?
console.log(
'More than one geometry column detected. Using first found.',
);
}
const location = labelMap[geojsonCols[0]];
const filter = geojsonCols.length ? [geojsonCols[0]] : [];
const leftOverKey = createColumnName(labelMap, filter);
if (!Object.keys(locations).includes(location)) {
locations[location] = [];
}
let dataAtX = locations[location].find(
i => i[params.x_axis] === d[params.x_axis],
);
if (!dataAtX) {
dataAtX = {
// add the x_axis value explicitly, since we
// filtered it out for the rest of the computation.
[params.x_axis]: d[params.x_axis],
};
locations[location].push(dataAtX);
}
dataAtX[leftOverKey] = d[k];
});
});
return locations;
};
/**
* Group data by location.
*
* @param data The incoming dataset
* @param geomColumn The name of the geometry column
* @returns The grouped data
*/
export const groupByLocation = (data: DataRecord[], geomColumn: string) => {
const locations: LocationConfigMapping = {};
data.forEach(d => {
const loc = d[geomColumn] as string;
if (!loc) {
return;
}
if (!Object.keys(locations).includes(loc)) {
locations[loc] = [];
}
const newData = {
...d,
};
delete newData[geomColumn];
locations[loc].push(newData);
});
return locations;
};
/**
* Strips the geom from colnames and coltypes.
*
* @param queryData The querydata.
* @param geomColumn Name of the geom column.
* @returns colnames and coltypes without the geom.
*/
export const stripGeomFromColnamesAndTypes = (
queryData: any,
geomColumn: string,
) => {
const newColnames: string[] = [];
const newColtypes: number[] = [];
queryData.colnames?.forEach((colname: string, idx: number) => {
if (colname === geomColumn) {
return;
}
const parts = colname.split(COLUMN_SEPARATOR);
const geojsonColumns = getGeojsonColumns(parts);
const filter = geojsonColumns.length ? [geojsonColumns[0]] : [];
const newColname = createColumnName(parts, filter);
if (newColnames.includes(newColname)) {
return;
}
newColnames.push(newColname);
newColtypes.push(queryData.coltypes[idx]);
});
return {
colnames: newColnames,
coltypes: newColtypes,
};
};
/**
* Strips the geom from labelMap.
*
* @param queryData The querydata.
* @param geomColumn Name of the geom column.
* @returns labelMap without the geom column.
*/
export const stripGeomColumnFromLabelMap = (
labelMap: { [key: string]: string[] },
geomColumn: string,
) => {
const newLabelMap = {};
Object.entries(labelMap).forEach(([key, value]) => {
if (key === geomColumn) {
return;
}
const geojsonCols = getGeojsonColumns(value);
const filter = geojsonCols.length ? [geojsonCols[0]] : [];
const columnName = createColumnName(value, filter);
const restItems = value.filter((v, idx) => !geojsonCols.includes(idx));
newLabelMap[columnName] = restItems;
});
return newLabelMap;
};
/**
* Strip occurrences of the geom column from the query data.
*
* @param queryDataClone The query data
* @param geomColumn The name of the geom column
* @returns query data without geom column.
*/
export const stripGeomColumnFromQueryData = (
queryData: any,
geomColumn: string,
) => {
const queryDataClone = {
...structuredClone(queryData),
...stripGeomFromColnamesAndTypes(queryData, geomColumn),
};
if (queryDataClone.label_map) {
queryDataClone.label_map = stripGeomColumnFromLabelMap(
queryData.label_map,
geomColumn,
);
}
return queryDataClone;
};
/**
* Create the chart configurations depending on the referenced Superset chart.
*
* @param selectedChart The configuration of the referenced Superset chart
* @param geomColumn The name of the geometry column
* @param chartProps The properties provided within this OL plugin
* @param chartTransformer The transformer function
* @returns The chart configurations
*/
export const getChartConfigs = (
selectedChart: SelectedChartConfig,
geomColumn: string,
chartProps: ChartProps,
chartTransformer: any,
) => {
const chartFormDataSnake = selectedChart.params;
const chartFormData = convertKeysToCamelCase(chartFormDataSnake);
const baseConfig = {
...chartProps,
// We overwrite width and height, which are not needed
// here, but leads to unnecessary updating of the UI.
width: null,
height: null,
formData: chartFormData,
rawFormData: chartFormDataSnake,
datasource: {},
};
const { queriesData } = chartProps;
const [queryData] = queriesData;
const data = queryData.data as DataRecord[];
let dataByLocation: LocationConfigMapping;
const chartConfigs: ChartConfig = {
type: 'FeatureCollection',
features: [],
};
if (!data) {
return chartConfigs;
}
if ('x_axis' in selectedChart.params) {
dataByLocation = groupByLocationGenericX(
data,
selectedChart.params,
queryData,
);
} else {
dataByLocation = groupByLocation(data, geomColumn);
}
const strippedQueryData = stripGeomColumnFromQueryData(queryData, geomColumn);
Object.keys(dataByLocation).forEach(location => {
const config = {
...baseConfig,
queriesData: [
{
...strippedQueryData,
data: dataByLocation[location],
},
],
};
const transformedProps = chartTransformer(config);
const feature: ChartConfigFeature = {
type: 'Feature',
geometry: JSON.parse(location),
properties: {
...transformedProps,
},
};
chartConfigs.features.push(feature);
});
return chartConfigs;
};
/**
* Return the same chart configuration with parsed values for of the stringified "params" object.
*
* @param selectedChart Incoming chart configuration
* @returns Chart configuration with parsed values for "params"
*/
export const parseSelectedChart = (selectedChart: string) => {
const selectedChartParsed = JSON.parse(selectedChart);
selectedChartParsed.params = JSON.parse(selectedChartParsed.params);
return selectedChartParsed;
};

View File

@@ -0,0 +1,21 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const MAX_ZOOM_LEVEL = 28;
export const MIN_ZOOM_LEVEL = 0;

View File

@@ -0,0 +1,48 @@
/**
* 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 { ChartLayer } from '../../src/components/ChartLayer';
import { ChartLayerOptions } from '../../src/types';
describe('ChartLayer', () => {
it('creates div and loading mask', () => {
const options: ChartLayerOptions = {
chartVizType: 'pie',
};
const chartLayer = new ChartLayer(options);
expect(chartLayer.loadingMask).toBeDefined();
expect(chartLayer.div).toBeDefined();
});
it('can remove chart elements', () => {
const options: ChartLayerOptions = {
chartVizType: 'pie',
};
const chartLayer = new ChartLayer(options);
chartLayer.charts = [
{
htmlElement: document.createElement('div'),
},
];
chartLayer.removeAllChartElements();
expect(chartLayer.charts).toEqual([]);
});
});

View File

@@ -0,0 +1,33 @@
/**
* 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 { CartodiagramPlugin } from '../src';
/**
* The example tests in this file act as a starting point, and
* we encourage you to build more. These tests check that the
* plugin loads properly, and focus on `transformProps`
* to ake sure that data, controls, and props are all
* treated correctly (e.g. formData from plugin controls
* properly transform the data and/or any resulting props).
*/
describe('CartodiagramPlugin', () => {
it('exists', () => {
expect(CartodiagramPlugin).toBeDefined();
});
});

View File

@@ -0,0 +1,67 @@
/**
* 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 { getChartBuildQueryRegistry } from '@superset-ui/core';
import buildQuery from '../../src/plugin/buildQuery';
describe('CartodiagramPlugin buildQuery', () => {
const selectedChartParams = {
extra_form_data: {},
groupby: [],
};
const selectedChart = {
viz_type: 'pie',
params: JSON.stringify(selectedChartParams),
};
const formData = {
datasource: '5__table',
granularity_sqla: 'ds',
series: 'foo',
viz_type: 'my_chart',
selected_chart: JSON.stringify(selectedChart),
geom_column: 'geom',
};
let chartQueryBuilderMock: jest.MockedFunction<any>;
beforeEach(() => {
chartQueryBuilderMock = jest.fn();
const registry = getChartBuildQueryRegistry();
registry.registerValue('pie', chartQueryBuilderMock);
});
afterEach(() => {
// remove registered buildQuery
const registry = getChartBuildQueryRegistry();
registry.clear();
});
it('should call the buildQuery function of the referenced chart', () => {
buildQuery(formData);
expect(chartQueryBuilderMock.mock.calls).toHaveLength(1);
});
it('should build groupby with geom in form data', () => {
const expectedParams = { ...selectedChartParams, groupby: ['geom'] };
buildQuery(formData);
expect(chartQueryBuilderMock.mock.calls[0][0]).toEqual(expectedParams);
});
});

View File

@@ -0,0 +1,26 @@
/**
* 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 CartodiagramPlugin from '../../src/CartodiagramPlugin';
describe('CartodiagramPlugin', () => {
it('exists', () => {
expect(CartodiagramPlugin).toBeDefined();
});
});

View File

@@ -0,0 +1,150 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
ChartProps,
getChartTransformPropsRegistry,
supersetTheme,
} from '@superset-ui/core';
import { LayerConf, MapViewConfigs, ZoomConfigs } from '../../src/types';
import transformProps from '../../src/plugin/transformProps';
import {
groupedTimeseriesChartData,
groupedTimeseriesLabelMap,
} from '../testData';
describe('CartodiagramPlugin transformProps', () => {
const chartSize: ZoomConfigs = {
type: 'FIXED',
configs: {
height: 10,
width: 10,
zoom: 1,
},
values: {
1: {
height: 10,
width: 10,
},
},
};
const layerConfigs: LayerConf[] = [
{
type: 'XYZ',
title: 'foo',
url: 'example.com',
},
];
const mapView: MapViewConfigs = {
mode: 'FIT_DATA',
zoom: 1,
latitude: 0,
longitude: 0,
fixedZoom: 1,
fixedLatitude: 0,
fixedLongitude: 0,
};
// only minimal subset of actual params
const selectedChartParams = {
groupby: ['bar'],
x_axis: 'mydate',
};
const selectedChart = {
id: 1,
viz_type: 'pie',
slice_name: 'foo',
params: JSON.stringify(selectedChartParams),
};
const formData = {
viz_type: 'cartodiagram',
geomColumn: 'geom',
selectedChart: JSON.stringify(selectedChart),
chartSize,
layerConfigs,
mapView,
chartBackgroundColor: '#000000',
chartBackgroundBorderRadius: 5,
};
const chartProps = new ChartProps({
formData,
width: 800,
height: 600,
queriesData: [
{
data: groupedTimeseriesChartData,
label_map: groupedTimeseriesLabelMap,
},
],
theme: supersetTheme,
});
let chartTransformPropsPieMock: jest.MockedFunction<any>;
let chartTransformPropsTimeseriesMock: jest.MockedFunction<any>;
beforeEach(() => {
chartTransformPropsPieMock = jest.fn();
chartTransformPropsTimeseriesMock = jest.fn();
const registry = getChartTransformPropsRegistry();
registry.registerValue('pie', chartTransformPropsPieMock);
registry.registerValue(
'echarts_timeseries',
chartTransformPropsTimeseriesMock,
);
});
afterEach(() => {
// remove registered transformProps
const registry = getChartTransformPropsRegistry();
registry.clear();
});
it('should call the transform props function of the referenced chart', () => {
transformProps(chartProps);
expect(chartTransformPropsPieMock).toHaveBeenCalled();
expect(chartTransformPropsTimeseriesMock).not.toHaveBeenCalled();
});
it('should transform chart props for viz', () => {
const transformedProps = transformProps(chartProps);
expect(transformedProps).toEqual(
expect.objectContaining({
width: chartProps.width,
height: chartProps.height,
geomColumn: formData.geomColumn,
selectedChart: expect.objectContaining({
viz_type: selectedChart.viz_type,
params: selectedChartParams,
}),
// The actual test for the created chartConfigs
// will be done in transformPropsUtil.test.ts
chartConfigs: expect.objectContaining({
type: 'FeatureCollection',
}),
chartVizType: selectedChart.viz_type,
chartSize,
layerConfigs,
mapView,
chartBackgroundColor: formData.chartBackgroundColor,
chartBackgroundBorderRadius: formData.chartBackgroundBorderRadius,
}),
);
});
});

View File

@@ -0,0 +1,113 @@
/**
* 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.
*/
const coord1 = '[1,2]';
const coord2 = '[3,4]';
export const geom1 = `{"type":"Point","coordinates":${coord1}}`;
export const geom2 = `{"type":"Point","coordinates":${coord2}}`;
export const nonTimeSeriesChartData: any = [
{
geom: geom1,
my_value: 'apple',
my_count: 347,
},
{
geom: geom1,
my_value: 'apple',
my_count: 360,
},
{
geom: geom1,
my_value: 'lemon',
my_count: 335,
},
{
geom: geom1,
my_value: 'lemon',
my_count: 333,
},
{
geom: geom1,
my_value: 'lemon',
my_count: 353,
},
{
geom: geom1,
my_value: 'lemon',
my_count: 359,
},
{
geom: geom2,
my_value: 'lemon',
my_count: 347,
},
{
geom: geom2,
my_value: 'apple',
my_count: 335,
},
{
geom: geom2,
my_value: 'apple',
my_count: 356,
},
{
geom: geom2,
my_value: 'banana',
my_count: 218,
},
];
export const timeseriesChartData = [
{
[geom1]: 347,
[geom2]: 360,
mydate: 1564275000000,
},
{
[geom1]: 353,
[geom2]: 328,
mydate: 1564272000000,
},
];
export const groupedTimeseriesChartData = [
{
[`${geom1}, apple`]: 347,
[`${geom2}, apple`]: 360,
[`${geom1}, lemon`]: 352,
[`${geom2}, lemon`]: 364,
mydate: 1564275000000,
},
{
[`${geom1}, apple`]: 353,
[`${geom2}, apple`]: 328,
[`${geom1}, lemon`]: 346,
[`${geom2}, lemon`]: 333,
mydate: 1564272000000,
},
];
export const groupedTimeseriesLabelMap = {
[`${geom1}, apple`]: [geom1, 'apple'],
[`${geom2}, apple`]: [geom2, 'apple'],
[`${geom1}, lemon`]: [geom1, 'lemon'],
[`${geom2}, lemon`]: [geom2, 'lemon'],
};

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": [
"**/*",
"../types/**/*",
"../../../types/**/*"
],
"references": [
{
"path": "../../../packages/superset-ui-chart-controls"
},
{
"path": "../../../packages/superset-ui-core"
},
]
}

View File

@@ -0,0 +1,77 @@
/**
* 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 { ChartConfig } from '../../src/types';
import { isChartConfigEqual, simplifyConfig } from '../../src/util/chartUtil';
describe('chartUtil', () => {
const configA: ChartConfig = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [],
},
properties: {
refs: 'foo',
},
},
],
};
const configB: ChartConfig = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [],
},
properties: {
refs: 'foo',
foo: 'bar',
},
},
],
};
describe('simplifyConfig', () => {
it('removes the refs property from a feature', () => {
const simplifiedConfig = simplifyConfig(configA);
const propKeys = Object.keys(simplifiedConfig.features[0].properties);
expect(propKeys).toHaveLength(0);
});
});
describe('isChartConfigEqual', () => {
it('returns true, if configurations are equal', () => {
const isEqual = isChartConfigEqual(configA, structuredClone(configA));
expect(isEqual).toBe(true);
});
it('returns false if configurations are not equal', () => {
const isEqual = isChartConfigEqual(configA, configB);
expect(isEqual).toBe(false);
});
});
});

View File

@@ -0,0 +1,212 @@
/**
* 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 {
ControlPanelConfig,
CustomControlItem,
} from '@superset-ui/chart-controls';
import {
getLayerConfig,
selectedChartMutator,
} from '../../src/util/controlPanelUtil';
describe('controlPanelUtil', () => {
describe('getLayerConfig', () => {
it('returns the correct layer config', () => {
const layerConfigs: CustomControlItem = {
name: 'layer_configs',
config: {
type: 'dummy',
renderTrigger: true,
label: 'Layers',
default: [
{
type: 'XYZ',
url: 'http://example.com/',
title: 'dummy title',
attribution: 'dummy attribution',
},
],
description: 'The configuration for the map layers',
},
};
const controlPanel: ControlPanelConfig = {
controlPanelSections: [
{
label: 'Configuration',
expanded: true,
controlSetRows: [],
},
{
label: 'Map Options',
expanded: true,
controlSetRows: [
[
{
name: 'map_view',
config: {
type: 'dummy',
},
},
],
[layerConfigs],
],
},
{
label: 'Chart Options',
expanded: true,
controlSetRows: [],
},
],
};
const extractedLayerConfigs = getLayerConfig(controlPanel);
expect(extractedLayerConfigs).toEqual(layerConfigs);
});
});
describe('selectedChartMutator', () => {
it('returns empty array for empty inputs', () => {
const response = {};
const value = undefined;
const result = selectedChartMutator(response, value);
expect(result).toEqual([]);
});
it('returns parsed value if response is empty', () => {
const response = {};
const sliceName = 'foobar';
const value = JSON.stringify({
id: 278,
params: '',
slice_name: sliceName,
viz_type: 'pie',
});
const result = selectedChartMutator(response, value);
expect(result[0].label).toEqual(sliceName);
});
it('returns response options if no value is chosen', () => {
const sliceName1 = 'foo';
const sliceName2 = 'bar';
const response = {
result: [
{
id: 1,
params: '{}',
slice_name: sliceName1,
viz_type: 'viz1',
},
{
id: 2,
params: '{}',
slice_name: sliceName2,
viz_type: 'viz2',
},
],
};
const value = undefined;
const result = selectedChartMutator(response, value);
expect(result[0].label).toEqual(sliceName1);
expect(result[1].label).toEqual(sliceName2);
});
it('returns correct result if id of chosen config does not exist in response', () => {
const response = {
result: [
{
id: 1,
params: '{}',
slice_name: 'foo',
viz_type: 'viz1',
},
{
id: 2,
params: '{}',
slice_name: 'bar',
viz_type: 'viz2',
},
],
};
const value = JSON.stringify({
id: 3,
params: '{}',
slice_name: 'my-slice',
viz_type: 'pie',
});
const result = selectedChartMutator(response, value);
// collect all ids in a set to prevent double entries
const ids = new Set();
result.forEach((item: any) => {
const config = JSON.parse(item.value);
const { id } = config;
ids.add(id);
});
const threeDifferentIds = ids.size === 3;
expect(threeDifferentIds).toEqual(true);
});
it('returns correct result if id of chosen config already exists', () => {
const response = {
result: [
{
id: 1,
params: '{}',
slice_name: 'foo',
viz_type: 'viz1',
},
{
id: 2,
params: '{}',
slice_name: 'bar',
viz_type: 'viz2',
},
],
};
const value = JSON.stringify({
id: 1,
params: '{}',
slice_name: 'my-slice',
viz_type: 'pie',
});
const result = selectedChartMutator(response, value);
const itemsIdWithId1 = result.filter((item: any) => {
const config = JSON.parse(item.value);
const { id } = config;
return id === 1;
});
expect(itemsIdWithId1.length).toEqual(2);
const labelsEqual = itemsIdWithId1[0].label === itemsIdWithId1[1].label;
expect(labelsEqual).toEqual(false);
});
});
});

View File

@@ -0,0 +1,102 @@
/**
* 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 GeoJSON from 'ol/format/GeoJSON';
import { Point } from 'geojson';
import {
getExtentFromFeatures,
getProjectedCoordinateFromPointGeoJson,
} from '../../src/util/geometryUtil';
import { ChartConfig } from '../../src/types';
describe('geometryUtil', () => {
describe('getProjectedCoordinateFromPointGeoJson', () => {
it('returns a plausible result', () => {
const pointGeoJson: Point = {
type: 'Point',
coordinates: [6.6555, 49.74283],
};
const result = getProjectedCoordinateFromPointGeoJson(pointGeoJson);
expect(result.length).toEqual(2);
const valuesAreNumbers =
!Number.isNaN(result[0]) && !Number.isNaN(result[1]);
expect(valuesAreNumbers).toEqual(true);
});
});
describe('getExtentFromFeatures', () => {
it('computes correct extent with valid input', () => {
const expectedExtent = [1, 2, 3, 4];
const chartConfig: ChartConfig = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [expectedExtent[0], expectedExtent[1]],
},
properties: {
setDataMask: '',
labelMap: '',
labelMapB: '',
groupby: '',
selectedValues: '',
formData: '',
groupbyB: '',
seriesBreakdown: '',
legendData: '',
echartOptions: '',
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [expectedExtent[2], expectedExtent[3]],
},
properties: {
setDataMask: '',
labelMap: '',
labelMapB: '',
groupby: '',
selectedValues: '',
formData: '',
groupbyB: '',
seriesBreakdown: '',
legendData: '',
echartOptions: '',
},
},
],
};
const features = new GeoJSON().readFeatures(chartConfig);
const extent = getExtentFromFeatures(features);
expect(extent).toEqual(expectedExtent);
});
it('returns undefined on invalid input', () => {
const emptyExtent = getExtentFromFeatures([]);
expect(emptyExtent).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,95 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { WfsLayerConf } from '../../src/types';
import {
createLayer,
createWfsLayer,
createWmsLayer,
createXyzLayer,
} from '../../src/util/layerUtil';
describe('layerUtil', () => {
describe('createWmsLayer', () => {
it('exists', () => {
// function is trivial
expect(createWmsLayer).toBeDefined();
});
});
describe('createWfsLayer', () => {
it('properly applies style', async () => {
const colorToExpect = '#123456';
const wfsLayerConf: WfsLayerConf = {
title: 'osm:osm-fuel',
url: 'https://ows-demo.terrestris.de/geoserver/osm/wfs',
type: 'WFS',
version: '2.0.2',
typeName: 'osm:osm-fuel',
style: {
name: 'Default Style',
rules: [
{
name: 'Default Rule',
symbolizers: [
{
kind: 'Line',
color: '#000000',
width: 2,
},
{
kind: 'Mark',
wellKnownName: 'circle',
color: colorToExpect,
},
{
kind: 'Fill',
color: '#000000',
},
],
},
],
},
};
const wfsLayer = await createWfsLayer(wfsLayerConf);
const style = wfsLayer!.getStyle();
// @ts-ignore
expect(style!.length).toEqual(3);
const colorAtLayer = style![1].getImage().getFill().getColor();
expect(colorToExpect).toEqual(colorAtLayer);
});
});
describe('createXyzLayer', () => {
it('exists', () => {
// function is trivial
expect(createXyzLayer).toBeDefined();
});
});
describe('createLayer', () => {
it('exists', () => {
expect(createLayer).toBeDefined();
});
});
});

View File

@@ -0,0 +1,116 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Map from 'ol/Map.js';
import OSM from 'ol/source/OSM.js';
import TileLayer from 'ol/layer/Tile.js';
import View from 'ol/View.js';
import { ChartConfig } from '../../src/types';
import { fitMapToCharts } from '../../src/util/mapUtil';
describe('mapUtil', () => {
describe('fitMapToCharts', () => {
it('changes the center of the map', () => {
const chartConfig: ChartConfig = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [8.793, 53.04117],
},
properties: {
setDataMask: '',
labelMap: '',
labelMapB: '',
groupby: '',
selectedValues: '',
formData: '',
groupbyB: '',
seriesBreakdown: '',
legendData: '',
echartOptions: '',
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [10.61833, 51.8],
},
properties: {
setDataMask: '',
labelMap: '',
labelMapB: '',
groupby: '',
selectedValues: '',
formData: '',
groupbyB: '',
seriesBreakdown: '',
legendData: '',
echartOptions: '',
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [6.86883, 50.35667],
},
properties: {
setDataMask: '',
labelMap: '',
labelMapB: '',
groupby: '',
selectedValues: '',
formData: '',
groupbyB: '',
seriesBreakdown: '',
legendData: '',
echartOptions: '',
},
},
],
};
const initialCenter = [0, 0];
const olMap = new Map({
layers: [
new TileLayer({
source: new OSM(),
}),
],
target: 'map',
view: new View({
center: initialCenter,
zoom: 2,
}),
});
// should set center
fitMapToCharts(olMap, chartConfig);
const updatedCenter = olMap.getView().getCenter();
expect(initialCenter).not.toEqual(updatedCenter);
});
});
});

View File

@@ -0,0 +1,46 @@
/**
* 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 { isVersionBelow } from '../../src/util/serviceUtil';
describe('serviceUtil', () => {
describe('isVersionBelow', () => {
describe('WMS', () => {
it('recognizes the higher version', () => {
const result = isVersionBelow('1.3.0', '1.1.0', 'WMS');
expect(result).toEqual(false);
});
it('recognizes the lower version', () => {
const result = isVersionBelow('1.1.1', '1.3.0', 'WMS');
expect(result).toEqual(true);
});
});
describe('WFS', () => {
it('recognizes the higher version', () => {
const result = isVersionBelow('2.0.2', '1.1.0', 'WFS');
expect(result).toEqual(false);
});
it('recognizes the lower version', () => {
const result = isVersionBelow('1.1.0', '2.0.2', 'WFS');
expect(result).toEqual(true);
});
});
});
});

View File

@@ -0,0 +1,249 @@
/**
* 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 {
groupByLocation,
getChartConfigs,
parseSelectedChart,
getGeojsonColumns,
createColumnName,
groupByLocationGenericX,
stripGeomFromColnamesAndTypes,
stripGeomColumnFromLabelMap,
} from '../../src/util/transformPropsUtil';
import {
nonTimeSeriesChartData,
groupedTimeseriesChartData,
geom1,
geom2,
groupedTimeseriesLabelMap,
} from '../testData';
describe('transformPropsUtil', () => {
const groupedTimeseriesParams = {
x_axis: 'mydate',
};
const groupedTimeseriesQueryData = {
label_map: groupedTimeseriesLabelMap,
};
describe('getGeojsonColumns', () => {
it('gets the GeoJSON columns', () => {
const columns = ['foo', 'bar', geom1];
const result = getGeojsonColumns(columns);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(2);
});
it('gets multiple GeoJSON columns', () => {
const columns = ['foo', geom2, 'bar', geom1];
const result = getGeojsonColumns(columns);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(1);
expect(result[1]).toEqual(3);
});
it('returns empty array when no GeoJSON is included', () => {
const columns = ['foo', 'bar'];
const result = getGeojsonColumns(columns);
expect(result).toHaveLength(0);
});
});
describe('createColumnName', () => {
it('creates a columns name', () => {
const columns = ['foo', 'bar'];
const result = createColumnName(columns, []);
expect(result).toEqual('foo, bar');
});
it('ignores items provided by ignoreIdx', () => {
const columns = ['foo', 'bar', 'baz'];
const ignoreIdx = [1];
const result = createColumnName(columns, ignoreIdx);
expect(result).toEqual('foo, baz');
});
});
describe('groupByLocationGenericX', () => {
it('groups in the correct count of geometries', () => {
const result = groupByLocationGenericX(
groupedTimeseriesChartData,
groupedTimeseriesParams,
groupedTimeseriesQueryData,
);
const countOfGeometries = Object.keys(result).length;
expect(countOfGeometries).toEqual(2);
});
it('groups items by same geometry', () => {
const result = groupByLocationGenericX(
groupedTimeseriesChartData,
groupedTimeseriesParams,
groupedTimeseriesQueryData,
);
const allGeom1 = result[geom1].length === 2;
const allGeom2 = result[geom2].length === 2;
expect(allGeom1 && allGeom2).toBe(true);
});
});
describe('groupByLocation', () => {
it('groups in the correct count of geometries', () => {
const geometryColumn = 'geom';
const result = groupByLocation(nonTimeSeriesChartData, geometryColumn);
const countOfGeometries = Object.keys(result).length;
expect(countOfGeometries).toEqual(2);
});
it('groups items by same geometry', () => {
const geometryColumn = 'geom';
const result = groupByLocation(nonTimeSeriesChartData, geometryColumn);
const allGeom1 = result[geom1].length === 6;
const allGeom2 = result[geom2].length === 4;
expect(allGeom1 && allGeom2).toBe(true);
});
});
describe('stripGeomFromColnamesAndTypes', () => {
it('strips the geom from colnames with geom column', () => {
const queryData = {
colnames: ['foo', 'geom'],
coltypes: [0, 0],
};
const result = stripGeomFromColnamesAndTypes(queryData, 'geom');
expect(result).toEqual({
colnames: ['foo'],
coltypes: [0],
});
});
it('strips the geom from colnames with grouped columns', () => {
const queryData = {
colnames: ['foo', `bar, ${geom1}`],
coltypes: [0, 0],
};
const result = stripGeomFromColnamesAndTypes(queryData, 'geom');
expect(result).toEqual({
colnames: ['foo', 'bar'],
coltypes: [0, 0],
});
});
it('strips the geom from colnames with grouped columns without geom', () => {
const queryData = {
colnames: ['foo', `bar, baz`],
coltypes: [0, 0],
};
const result = stripGeomFromColnamesAndTypes(queryData, 'geom');
expect(result).toEqual({
colnames: ['foo', 'bar, baz'],
coltypes: [0, 0],
});
});
});
describe('stripGeomColumnFromLabelMap', () => {
it('strips the geom column from label_map', () => {
const labelMap = {
[`apple, ${geom1}`]: ['apple', geom1],
[`${geom2}, lemon`]: [geom2, 'lemon'],
geom: ['geom'],
};
const result = stripGeomColumnFromLabelMap(labelMap, 'geom');
expect(result).toEqual({
apple: ['apple'],
lemon: ['lemon'],
});
});
});
describe('getChartConfigs', () => {
let chartTransformer: jest.MockedFunction<any>;
const geomColumn = 'geom';
const pieChartConfig = {
params: {},
viz_type: 'pie',
};
const chartProps: any = {
queriesData: [
{
data: nonTimeSeriesChartData,
},
],
};
beforeEach(() => {
chartTransformer = jest.fn();
});
it('calls the transformProps function for every location', () => {
getChartConfigs(pieChartConfig, geomColumn, chartProps, chartTransformer);
expect(chartTransformer).toHaveBeenCalledTimes(2);
});
it('returns a geojson', () => {
const result = getChartConfigs(
pieChartConfig,
geomColumn,
chartProps,
chartTransformer,
);
expect(result).toEqual(
expect.objectContaining({
type: 'FeatureCollection',
features: expect.arrayContaining([
expect.objectContaining({
type: 'Feature',
}),
]),
}),
);
});
it('returns a feature for each location', () => {
const result = getChartConfigs(
pieChartConfig,
geomColumn,
chartProps,
chartTransformer,
);
expect(result.features).toHaveLength(2);
expect(result.features[0].geometry).toEqual(JSON.parse(geom1));
expect(result.features[1].geometry).toEqual(JSON.parse(geom2));
});
});
describe('parseSelectedChart', () => {
it('parses the inline stringified JSON', () => {
const selectedChartObject = {
id: 278,
params:
'{"adhoc_filters":[],"applied_time_extras":{},"datasource":"24__table","viz_type":"pie","time_range":"No filter","groupby":["nuclide"],"metric":{"expressionType":"SIMPLE","column":{"advanced_data_type":null,"certification_details":null,"certified_by":null,"column_name":"nuclide","description":null,"expression":null,"filterable":true,"groupby":true,"id":772,"is_certified":false,"is_dttm":false,"python_date_format":null,"type":"TEXT","type_generic":1,"verbose_name":null,"warning_markdown":null},"aggregate":"COUNT","sqlExpression":null,"isNew":false,"datasourceWarning":false,"hasCustomLabel":false,"label":"COUNT(nuclide)","optionName":"metric_k6d9mt9zujc_7v9szd1i0pl"},"dashboards":[]}',
slice_name: 'pie',
viz_type: 'pie',
};
const selectedChartString = JSON.stringify(selectedChartObject);
const result = parseSelectedChart(selectedChartString);
const expectedParams = JSON.parse(selectedChartObject.params);
expect(result.params).toEqual(expectedParams);
});
});
});

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"declarationDir": "lib",
"outDir": "lib",
"rootDir": "src"
},
"exclude": [
"lib",
"test"
],
"extends": "../../tsconfig.json",
"include": [
"src/**/*",
"types/**/*",
"../../types/**/*"
],
"references": [
{
"path": "../../packages/superset-ui-chart-controls"
},
{
"path": "../../packages/superset-ui-core"
},
]
}

View File

@@ -0,0 +1,28 @@
/*
* 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: any;
export default value;
}
declare module '*.gif' {
const value: any;
export default value;
}

View File

@@ -25,13 +25,13 @@
],
"dependencies": {
"d3-array": "^1.2.0",
"echarts": "^5.6.0",
"lodash": "^4.17.21",
"dayjs": "^1.11.13"
},
"peerDependencies": {
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"echarts": "*",
"memoize-one": "*",
"react": "^16.13.1"
},

View File

@@ -0,0 +1,146 @@
/**
* 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 { PlusOutlined } from '@ant-design/icons';
import { css, styled, t } from '@superset-ui/core';
import { Button, Tree } from 'antd';
import { TreeProps } from 'antd/lib/tree';
import { forwardRef } from 'react';
import { FlatLayerDataNode, FlatLayerTreeProps, LayerConf } from './types';
import { handleDrop } from './dragDropUtil';
import LayerTreeItem from './LayerTreeItem';
export const StyledLayerTreeItem = styled(LayerTreeItem)`
${({ theme }) => css`
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: unset;
border: none;
border-radius: ${theme.borderRadius}px;
background-color: ${theme.colors.grayscale.light3};
font-size: ${theme.typography.sizes.s}px;
font-weight: ${theme.typography.weights.normal};
&:hover {
background-color: ${theme.colors.grayscale.light3};
}
& .layer-tree-item-close {
border-right: solid;
border-right-width: 1px;
border-right-color: ${theme.colors.grayscale.light2};
}
& .layer-tree-item-edit {
border-left: solid;
border-left-width: 1px;
border-left-color: ${theme.colors.grayscale.light2};
}
& .layer-tree-item-title {
flex: 1;
padding-left: 4px;
}
& .layer-tree-item-type {
padding-left: 4px;
font-size: ${theme.typography.sizes.xs}px;
font-family: ${theme.typography.families.monospace};
}
& > button {
border: none;
background-color: unset;
color: ${theme.colors.grayscale.light1};
}
& > button:hover {
background-color: unset;
color: ${theme.colors.grayscale.light1};
}
`}
`;
// forwardRef is needed here in order for emotion and antd tree to work properly
export const FlatLayerTree = forwardRef<HTMLDivElement, FlatLayerTreeProps>(
(
{
layerConfigs,
onAddLayer = () => {},
onRemoveLayer = () => {},
onEditLayer = () => {},
onMoveLayer = () => {},
draggable,
className,
},
ref,
) => {
const layerConfigsToTreeData = (
configs: LayerConf[],
): FlatLayerDataNode[] =>
configs.map((config, idx) => ({
layerConf: config,
key: idx,
title: (
<StyledLayerTreeItem
layerConf={config}
onEditClick={() => onEditLayer(config, idx)}
onRemoveClick={() => onRemoveLayer(idx)}
/>
),
selectable: false,
isLeaf: true,
checkable: false,
}));
const treeDataToLayerConfigs = (
treeData: FlatLayerDataNode[],
): LayerConf[] => treeData.map(data => data.layerConf);
const treeData = layerConfigsToTreeData(layerConfigs);
const onDrop: TreeProps['onDrop'] = info => {
const data = handleDrop(info, treeData);
const movedLayerConfigs = treeDataToLayerConfigs(data);
onMoveLayer(movedLayerConfigs);
};
const addLayerLabel = t('Click to add new layer');
return (
<div className={className} ref={ref}>
<Button
className="add-layer-btn"
onClick={onAddLayer}
size="small"
type="dashed"
icon={<PlusOutlined />}
>
{addLayerLabel}
</Button>
<Tree treeData={treeData} draggable={draggable} onDrop={onDrop} />
</div>
);
},
);
export default FlatLayerTree;

View File

@@ -0,0 +1,38 @@
/**
* 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.
*/
/**
* This component is needed to be able to style GeoStyler
* via emotion. Emotion can only be used on a component that
* accepts a className property.
*/
import CardStyle from 'geostyler/dist/Component/CardStyle/CardStyle';
import { FC } from 'react';
import { GeoStylerWrapperProps } from './types';
export const GeoStylerWrapper: FC<GeoStylerWrapperProps> = ({
className,
...passThroughProps
}) => (
<div className={className}>
<CardStyle {...passThroughProps} />
</div>
);
export default GeoStylerWrapper;

View File

@@ -0,0 +1,193 @@
/**
* 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 { ControlHeader } from '@superset-ui/chart-controls';
import { css, styled, t } from '@superset-ui/core';
import { Popover } from 'antd';
import { FC, useState } from 'react';
import { EditItem, LayerConf, LayerConfigsControlProps } from './types';
import LayerConfigsPopoverContent from './LayerConfigsPopoverContent';
import FlatLayerTree from './FlatLayerTree';
export const StyledFlatLayerTree = styled(FlatLayerTree)`
${({ theme }) => css`
display: flex;
flex-direction: column;
border: solid;
border-width: 1px;
border-radius: ${theme.borderRadius}px;
border-color: ${theme.colors.grayscale.light2};
& .add-layer-btn {
display: flex;
align-items: center;
margin: 4px;
color: ${theme.colors.grayscale.light1};
font-size: ${theme.typography.sizes.s}px;
font-weight: ${theme.typography.weights.normal};
&:hover {
background-color: ${theme.colors.grayscale.light4};
border-color: ${theme.colors.grayscale.light2};
}
}
& .ant-tree .ant-tree-treenode {
display: block;
}
& .ant-tree-list-holder-inner {
display: block !important;
}
& .ant-tree-node-content-wrapper {
display: block;
}
& .ant-tree-node-content-wrapper:hover {
background-color: unset;
}
`}
`;
const getEmptyEditItem = (): EditItem => ({
idx: NaN,
layerConf: {
type: 'WMS',
version: '1.3.0',
title: '',
url: '',
layersParam: '',
},
});
export const LayerConfigsControl: FC<LayerConfigsControlProps> = ({
value,
onChange = () => {},
name,
label,
description,
renderTrigger,
hovered,
validationErrors,
}) => {
const [popoverVisible, setPopoverVisible] = useState<boolean>(false);
const [editItem, setEditItem] = useState<EditItem>(getEmptyEditItem());
const onAddClick = () => {
setEditItem(getEmptyEditItem());
setPopoverVisible(true);
};
const onEditClick = (layerConf: LayerConf, idx: number) => {
if (popoverVisible) {
return;
}
setEditItem({
idx,
layerConf: { ...layerConf },
});
setPopoverVisible(true);
};
const onRemoveClick = (idx: number) => {
const newValue = value ? [...value] : [];
newValue.splice(idx, 1);
onChange(newValue);
};
const onPopoverClose = () => {
setPopoverVisible(false);
};
const computeNewValue = (layerConf: LayerConf) => {
const newValue = value ? [...value] : [];
if (!editItem) {
return undefined;
}
if (Number.isNaN(editItem.idx)) {
newValue.unshift(layerConf);
} else if (editItem) {
newValue[editItem.idx] = layerConf;
}
return newValue;
};
const onPopoverSave = (layerConf: LayerConf) => {
const newValue = computeNewValue(layerConf);
setPopoverVisible(false);
if (!newValue) {
return;
}
onChange(newValue);
};
const onMoveLayer = (newConfigs: LayerConf[]) => {
onChange(newConfigs);
};
const popoverTitle = editItem.layerConf.title
? editItem.layerConf.title
: t('Add Layer');
const controlHeaderProps = {
name,
label,
description,
renderTrigger,
hovered,
validationErrors,
};
return (
<div>
<ControlHeader {...controlHeaderProps} />
<Popover
visible={popoverVisible}
trigger="click"
title={popoverTitle}
placement="right"
overlayStyle={{
maxWidth: '400px',
maxHeight: '700px',
overflowY: 'auto',
}}
content={
<LayerConfigsPopoverContent
layerConf={editItem.layerConf}
onClose={onPopoverClose}
onSave={onPopoverSave}
/>
}
>
<StyledFlatLayerTree
layerConfigs={value ?? []}
onMoveLayer={onMoveLayer}
onEditLayer={onEditClick}
onRemoveLayer={onRemoveClick}
onAddLayer={onAddClick}
draggable={!popoverVisible}
/>
</Popover>
</div>
);
};
export default LayerConfigsControl;

View File

@@ -0,0 +1,506 @@
/**
* 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 { css, JsonValue, styled, t } from '@superset-ui/core';
import { Button, Form, Tabs } from 'antd';
import { mix } from 'polished';
import { Data as GsData } from 'geostyler-data';
import { Style as GsStyle } from 'geostyler-style';
import WfsDataParser, {
RequestParams1_1_0,
RequestParams2_0_0,
} from 'geostyler-wfs-parser';
import { FC, useEffect, useState } from 'react';
import { isWfsLayerConf, isWmsLayerConf, isXyzLayerConf } from './typeguards';
import {
BaseLayerConf,
LayerConf,
LayerConfigsPopoverContentProps,
WfsLayerConf,
WmsLayerConf,
XyzLayerConf,
} from './types';
import { getServiceVersions, hasAllRequiredWfsParams } from './serviceUtil';
import { ControlFormItem } from '../ColumnConfigControl/ControlForm';
import GeoStylerWrapper from './GeoStylerWrapper';
// Enum for the different tabs
const LAYER_CONFIG_TABS = {
LAYER: '1',
GEOSTYLER: '2',
};
export const StyledButtonContainer = styled.div`
display: flex;
margin: 8px;
`;
export const StyledCloseButton = styled(Button)`
${({ theme }) => css`
flex: 1;
margin-right: 4px;
line-height: 1.5715;
border-radius: ${theme.borderRadius}px;
background-color: ${theme.colors.primary.light4};
color: ${theme.colors.primary.dark1};
font-size: ${theme.typography.sizes.s}px;
font-weight: ${theme.typography.weights.bold};
text-transform: uppercase;
min-width: ${theme.gridUnit * 36};
min-height: ${theme.gridUnit * 8};
box-shadow: none;
border-width: 0px;
border-style: none;
border-color: transparent;
&:hover {
background-color: ${mix(
0.1,
theme.colors.primary.base,
theme.colors.primary.light4,
)};
color: ${theme.colors.primary.dark1};
}
`}
`;
export const StyledControlFormItem = styled(ControlFormItem)`
${({ theme }) => css`
border-radius: ${theme.borderRadius}px;
`}
`;
export const StyledControlNumberFormItem = styled(ControlFormItem)`
${({ theme }) => css`
border-radius: ${theme.borderRadius}px;
width: 100%;
`}
`;
export const StyledGeoStyler = styled(GeoStylerWrapper)`
${({ theme }) => css`
h2 {
font-weight: ${theme.typography.weights.normal};
font-size: ${theme.typography.sizes.xl}px;
}
.ant-form-item-control {
flex: unset;
}
`}
`;
export const StyledSaveButton = styled(Button)`
${({ theme }) => css`
flex: 1;
margin-left: 4px;
line-height: 1.5715;
border-radius: ${theme.borderRadius}px;
background-color: ${theme.colors.primary.base};
color: ${theme.colors.grayscale.light5};
font-size: ${theme.typography.sizes.s}px;
font-weight: ${theme.typography.weights.bold};
text-transform: uppercase;
min-width: ${theme.gridUnit * 36};
min-height: ${theme.gridUnit * 8};
box-shadow: none;
border-width: 0px;
border-style: none;
border-color: transparent;
&:hover {
background-color: ${theme.colors.primary.dark1};
}
`}
`;
export const LayerConfigsPopoverContent: FC<
LayerConfigsPopoverContentProps
> = ({ onClose = () => {}, onSave = () => {}, layerConf }) => {
const [currentLayerConf, setCurrentLayerConf] =
useState<LayerConf>(layerConf);
const initialWmsVersion =
layerConf.type === 'WMS' ? layerConf.version : undefined;
const [wmsVersion, setWmsVersion] = useState<string | undefined>(
initialWmsVersion,
);
const initialWfsVersion =
layerConf.type === 'WFS' ? layerConf.version : undefined;
const [wfsVersion, setWfsVersion] = useState<string | undefined>(
initialWfsVersion,
);
const [geostylerData, setGeoStylerData] = useState<GsData | undefined>(
undefined,
);
const serviceVersions = getServiceVersions();
// This is needed to force mounting the form every time
// we get a new layerConf prop. Otherwise the input fields
// will not be updated properly, since ControlFormItem only
// recognises the `value` property once and then handles the
// values in its on state. Remounting creates a new component
// and thereby starts with a fresh state.
const [formKey, setFormKey] = useState<number>(0);
useEffect(() => {
setCurrentLayerConf({ ...layerConf });
setFormKey(oldFormKey => oldFormKey + 1);
}, [layerConf]);
const onFieldValueChange = (value: JsonValue, key: string) => {
setCurrentLayerConf({
...currentLayerConf,
[key]: value,
});
};
const onLayerTypeChange = (value: LayerConf['type']) => {
if (value === 'WFS') {
setCurrentLayerConf({
...currentLayerConf,
type: value,
version: serviceVersions[value][0],
style: {
name: 'Default Style',
rules: [
{
name: 'Default Rule',
symbolizers: [
{
kind: 'Line',
// eslint-disable-next-line theme-colors/no-literal-colors
color: '#000000',
width: 2,
},
{
kind: 'Mark',
wellKnownName: 'circle',
// eslint-disable-next-line theme-colors/no-literal-colors
color: '#000000',
},
{
kind: 'Fill',
// eslint-disable-next-line theme-colors/no-literal-colors
color: '#000000',
},
],
},
],
},
} as WfsLayerConf);
} else if (value === 'XYZ') {
setCurrentLayerConf({
...currentLayerConf,
type: value,
} as XyzLayerConf);
} else {
setCurrentLayerConf({
...currentLayerConf,
type: value,
version: serviceVersions[value][0],
} as WmsLayerConf);
}
};
const onLayerTitleChange = (fieldValue: string) => {
onFieldValueChange(fieldValue, 'title');
};
const onLayerUrlChange = (fieldValue: string) => {
onFieldValueChange(fieldValue, 'url');
};
const onLayersParamChange = (fieldValue: string) => {
onFieldValueChange(fieldValue, 'layersParam');
};
const onTypeNameChange = (fieldValue: string) => {
onFieldValueChange(fieldValue, 'typeName');
};
const onWmsVersionChange = (fieldValue: string) => {
onFieldValueChange(fieldValue, 'version');
setWmsVersion(fieldValue);
};
const onWfsVersionChange = (fieldValue: string) => {
onFieldValueChange(fieldValue, 'version');
setWfsVersion(fieldValue);
};
const onMaxFeaturesChange = (fieldValue: number) => {
onFieldValueChange(fieldValue, 'maxFeatures');
};
const onStyleChange = (fieldValue: GsStyle) => {
onFieldValueChange(fieldValue, 'style');
};
const onAttributionChange = (fieldValue: string) => {
onFieldValueChange(fieldValue, 'attribution');
};
const onCloseClick = () => {
onClose();
};
const onSaveClick = () => {
const baseConfs: BaseLayerConf = {
title: currentLayerConf.title,
url: currentLayerConf.url,
type: currentLayerConf.type,
attribution: currentLayerConf.attribution,
};
let conf: LayerConf;
if (isWmsLayerConf(currentLayerConf)) {
conf = {
...baseConfs,
version: currentLayerConf.version,
type: currentLayerConf.type,
layersParam: currentLayerConf.layersParam,
};
} else if (isXyzLayerConf(currentLayerConf)) {
conf = {
...baseConfs,
type: currentLayerConf.type,
};
} else {
conf = {
...baseConfs,
type: currentLayerConf.type,
version: currentLayerConf.version,
typeName: currentLayerConf.typeName,
maxFeatures: currentLayerConf.maxFeatures,
style: currentLayerConf.style,
};
}
onSave(conf);
};
useEffect(() => {
if (
!isWfsLayerConf(currentLayerConf) ||
!hasAllRequiredWfsParams(currentLayerConf)
) {
setGeoStylerData(undefined);
return undefined;
}
const readWfsData = async (conf: WfsLayerConf) => {
const wfsParser = new WfsDataParser();
try {
let requestParams: RequestParams1_1_0 | RequestParams2_0_0 = {} as
| RequestParams1_1_0
| RequestParams2_0_0;
if (conf.version.startsWith('1.')) {
requestParams = {
version: conf.version as RequestParams1_1_0['version'],
maxFeatures: conf.maxFeatures,
typeName: conf.typeName,
};
}
if (conf.version.startsWith('2.')) {
requestParams = {
version: conf.version as RequestParams2_0_0['version'],
count: conf.maxFeatures,
typeNames: conf.typeName,
};
}
const gsData = await wfsParser.readData({
url: conf.url,
requestParams,
});
setGeoStylerData(gsData);
} catch {
console.warn('Could not read geostyler data');
setGeoStylerData(undefined);
}
};
// debounce function
const timer = setTimeout(() => readWfsData(currentLayerConf), 500);
return () => {
clearTimeout(timer);
};
}, [currentLayerConf]);
const layerTabLabel = t('Layer');
const styleTabLabel = t('Style');
const layerTypeLabel = t('Layer type');
const layerTypeDescription = t('The type of the layer');
const serviceVersionLabel = t('Service version');
const serviceVersionDescription = t('The version of the service');
const layersParamLabel = t('Layer Name');
const layersParamDescription = t(
'The name of the layer as described in GetCapabilities',
);
const layersParamPlaceholder = t('Layer Name');
const layerTitleLabel = t('Layer title');
const layerTitleDescription = t('The visible title of the layer');
const layerTitlePlaceholder = t('Insert Layer title');
const layerUrlLabel = t('Layer URL');
const layerUrlDescription = t('The service url of the layer');
const layerUrlPlaceholder = t('Insert Layer URL');
const maxFeaturesLabel = t('Max. features');
const maxFeaturesDescription = t(
'Maximum number of features to fetch from service',
);
const maxFeaturesPlaceholder = t('10000');
const attributionLabel = t('Attribution');
const attributionDescription = t('The layer attribution');
const attributionPlaceholder = t('© Layer attribution');
const wmsVersionOptions: { value: any; label: string }[] =
serviceVersions.WMS.map(version => ({ value: version, label: version }));
const wfsVersionOptions: { value: any; label: string }[] =
serviceVersions.WFS.map(version => ({ value: version, label: version }));
return (
<div>
<Form key={JSON.stringify(formKey)}>
<Tabs defaultActiveKey={LAYER_CONFIG_TABS.LAYER}>
<Tabs.TabPane tab={layerTabLabel} key={LAYER_CONFIG_TABS.LAYER}>
<StyledControlFormItem
controlType="Input"
label={layerUrlLabel}
description={layerUrlDescription}
placeholder={layerUrlPlaceholder}
value={currentLayerConf.url}
name="url"
onChange={onLayerUrlChange}
/>
<StyledControlFormItem
controlType="Select"
label={layerTypeLabel}
description={layerTypeDescription}
options={[
{ value: 'WMS', label: t('WMS') },
{ value: 'WFS', label: t('WFS') },
{ value: 'XYZ', label: t('XYZ') },
]}
value={currentLayerConf.type}
defaultValue={currentLayerConf.type}
name="type"
onChange={onLayerTypeChange}
/>
{isWmsLayerConf(currentLayerConf) && (
<StyledControlFormItem
controlType="Select"
label={serviceVersionLabel}
description={serviceVersionDescription}
options={wmsVersionOptions}
value={wmsVersion}
defaultValue={wmsVersionOptions[0].value as string}
name="wmsVersion"
onChange={onWmsVersionChange}
/>
)}
{isWfsLayerConf(currentLayerConf) && (
<StyledControlFormItem
controlType="Select"
label={serviceVersionLabel}
description={serviceVersionDescription}
options={wfsVersionOptions}
value={wfsVersion}
defaultValue={wfsVersionOptions[0].value as string}
name="wfsVersion"
onChange={onWfsVersionChange}
/>
)}
{isWmsLayerConf(currentLayerConf) && (
<StyledControlFormItem
controlType="Input"
label={layersParamLabel}
description={layersParamDescription}
placeholder={layersParamPlaceholder}
value={currentLayerConf.layersParam}
name="layersParam"
onChange={onLayersParamChange}
/>
)}
{isWfsLayerConf(currentLayerConf) && (
<StyledControlFormItem
controlType="Input"
label={layersParamLabel}
description={layersParamDescription}
placeholder={layersParamPlaceholder}
value={currentLayerConf.typeName}
name="typeName"
onChange={onTypeNameChange}
/>
)}
<StyledControlFormItem
controlType="Input"
label={layerTitleLabel}
description={layerTitleDescription}
placeholder={layerTitlePlaceholder}
value={currentLayerConf.title}
name="title"
onChange={onLayerTitleChange}
/>
{isWfsLayerConf(currentLayerConf) && (
<StyledControlNumberFormItem
controlType="InputNumber"
label={maxFeaturesLabel}
description={maxFeaturesDescription}
placeholder={maxFeaturesPlaceholder}
value={currentLayerConf.maxFeatures}
name="maxFeatures"
onChange={onMaxFeaturesChange}
/>
)}
<StyledControlFormItem
controlType="Input"
label={attributionLabel}
description={attributionDescription}
placeholder={attributionPlaceholder}
value={currentLayerConf.attribution}
name="attribution"
onChange={onAttributionChange}
/>
</Tabs.TabPane>
<Tabs.TabPane
tab={styleTabLabel}
key={LAYER_CONFIG_TABS.GEOSTYLER}
disabled={!isWfsLayerConf(currentLayerConf)}
>
{isWfsLayerConf(currentLayerConf) && (
<StyledGeoStyler
style={currentLayerConf.style}
onStyleChange={onStyleChange}
data={geostylerData}
/>
)}
</Tabs.TabPane>
</Tabs>
<StyledButtonContainer>
<StyledCloseButton type="default" onClick={onCloseClick}>
{t('Close')}
</StyledCloseButton>
<StyledSaveButton type="primary" onClick={onSaveClick}>
{t('Save')}
</StyledSaveButton>
</StyledButtonContainer>
</Form>
</div>
);
};
export default LayerConfigsPopoverContent;

View File

@@ -0,0 +1,72 @@
/**
* 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 { CloseOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Tag } from 'antd';
import { FC } from 'react';
import { LayerTreeItemProps } from './types';
export const LayerTreeItem: FC<LayerTreeItemProps> = ({
layerConf,
onEditClick = () => {},
onRemoveClick = () => {},
className,
}) => {
const onCloseTag = () => {
onRemoveClick();
};
const onEditTag = () => {
onEditClick();
};
return (
<Tag className={className}>
<Button
className="layer-tree-item-close"
icon={<CloseOutlined />}
onClick={onCloseTag}
size="small"
/>
<span
className="layer-tree-item-type"
onClick={onEditTag}
role="button"
tabIndex={0}
>
{layerConf.type}
</span>
<span
className="layer-tree-item-title"
onClick={onEditTag}
role="button"
tabIndex={0}
>
{layerConf.title}
</span>
<Button
className="layer-tree-item-edit"
icon={<RightOutlined />}
onClick={onEditTag}
size="small"
/>
</Tag>
);
};
export default LayerTreeItem;

View File

@@ -0,0 +1,64 @@
/**
* 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 { TreeProps } from 'antd/lib/tree';
import { DropInfoType, FlatLayerDataNode } from './types';
/**
* Util for drag and drop related operations.
*/
/**
* Handle drop of flat antd tree.
*
* Functionality is roughly based on antd tree examples:
* https://ant.design/components/tree/
*
* @param info The argument of the onDrop callback.
* @param treeData The list of DataNodes on which the drop event occurred.
* @returns A copy of the list with the new sorting.
*/
export const handleDrop = (
info: DropInfoType<TreeProps['onDrop']>,
treeData: FlatLayerDataNode[],
) => {
if (info === undefined) {
return [...treeData];
}
const dropKey = info.node.key;
const dragKey = info.dragNode.key;
const dropPos = info.node.pos.split('-');
const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);
const data = [...treeData];
const dragObjIndex = data.findIndex(d => d.key === dragKey);
const dragObj = data[dragObjIndex];
data.splice(dragObjIndex, 1);
const dropObjIndex = data.findIndex(d => d.key === dropKey);
if (dropPosition === -1) {
data.splice(dropObjIndex, 0, dragObj!);
} else {
data.splice(dropObjIndex + 1, 0, dragObj!);
}
return data;
};

View File

@@ -0,0 +1,39 @@
/**
* 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 { WfsLayerConf } from './types';
/**
* Get the available versions of WFS and WMS.
*
* @returns the versions
*/
export const getServiceVersions = () => ({
WMS: ['1.3.0', '1.1.1'],
WFS: ['2.0.2', '2.0.0', '1.1.0'],
});
/**
* Checks if all required WFS params are provided.
*
* @param layerConf The config to check
* @returns True, if all required params are provided. False, otherwise.
*/
export const hasAllRequiredWfsParams = (layerConf: WfsLayerConf) =>
layerConf.url && layerConf.version && layerConf.typeName;

View File

@@ -0,0 +1,31 @@
/**
* 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 { LayerConf, WfsLayerConf, WmsLayerConf, XyzLayerConf } from './types';
export const isWmsLayerConf = (
layerConf: LayerConf,
): layerConf is WmsLayerConf => layerConf.type === 'WMS';
export const isWfsLayerConf = (
layerConf: LayerConf,
): layerConf is WfsLayerConf => layerConf.type === 'WFS';
export const isXyzLayerConf = (
layerConf: LayerConf,
): layerConf is XyzLayerConf => layerConf.type === 'XYZ';

View File

@@ -0,0 +1,92 @@
/**
* 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 { DataNode, TreeProps } from 'antd/lib/tree';
import { ControlComponentProps } from '@superset-ui/chart-controls';
import { Style } from 'geostyler-style';
import { CardStyleProps } from 'geostyler/dist/Component/CardStyle/CardStyle';
export interface BaseLayerConf {
title: string;
url: string;
type: string;
attribution?: string;
}
export interface WfsLayerConf extends BaseLayerConf {
type: 'WFS';
typeName: string;
version: string;
maxFeatures?: number;
style?: Style;
}
export interface XyzLayerConf extends BaseLayerConf {
type: 'XYZ';
}
export interface WmsLayerConf extends BaseLayerConf {
type: 'WMS';
version: string;
layersParam: string;
}
export interface FlatLayerDataNode extends DataNode {
layerConf: LayerConf;
}
export interface FlatLayerTreeProps {
layerConfigs: LayerConf[];
onAddLayer?: () => void;
onRemoveLayer?: (idx: number) => void;
onEditLayer?: (layerConf: LayerConf, idx: number) => void;
onMoveLayer?: (layerConfigs: LayerConf[]) => void;
draggable?: boolean;
className?: string;
}
export type LayerConf = WmsLayerConf | WfsLayerConf | XyzLayerConf;
export type DropInfoType<T extends TreeProps['onDrop']> = T extends Function
? Parameters<T>[0]
: undefined;
export interface EditItem {
layerConf: LayerConf;
idx: number;
}
export type LayerConfigsControlProps = ControlComponentProps<LayerConf[]>;
export interface LayerConfigsPopoverContentProps {
onClose?: () => void;
onSave?: (layerConf: LayerConf) => void;
layerConf: LayerConf;
}
export interface GeoStylerWrapperProps extends CardStyleProps {
className?: string;
}
export interface LayerTreeItemProps {
layerConf: LayerConf;
onEditClick?: () => void;
onRemoveClick?: () => void;
className?: string;
}

View File

@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import { Tag } from 'antd';
import { FC } from 'react';
import { ExtentTagProps } from './types';
export const ExtentTag: FC<ExtentTagProps> = ({
value,
onClick,
className,
}) => {
const unsetName = t('unset');
const zoomName = t('Zoom');
const latName = t('Lat');
const lonName = t('Lon');
return (
<Tag onClick={onClick} className={className}>
{zoomName}: {value.fixedZoom ? Math.round(value.fixedZoom) : unsetName}
{' | '}
{latName}:{' '}
{value.fixedLatitude ? value.fixedLatitude.toFixed(6) : unsetName}
{' | '}
{lonName}:{' '}
{value.fixedLongitude ? value.fixedLongitude.toFixed(6) : unsetName}
</Tag>
);
};
export default ExtentTag;

View File

@@ -0,0 +1,192 @@
/**
* 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 { ControlHeader } from '@superset-ui/chart-controls';
import { css, styled, t } from '@superset-ui/core';
import { Button, Popover } from 'antd';
import { FC, useState } from 'react';
import { mix } from 'polished';
import { MapViewConfigs, MapViewConfigsControlProps } from './types';
import MapViewPopoverContent from './MapViewPopoverContent';
import ExtentTag from './ExtentTag';
import { ControlFormItem } from '../ColumnConfigControl/ControlForm';
export const StyledExtentButton = styled(Button)`
${({ theme }) => css`
flex: 1;
margin-right: 4px;
line-height: 1.5715;
border-radius: ${theme.borderRadius}px;
background-color: ${theme.colors.primary.light4};
color: ${theme.colors.primary.dark1};
font-size: ${theme.typography.sizes.s}px;
font-weight: ${theme.typography.weights.bold};
text-transform: uppercase;
min-width: ${theme.gridUnit * 36};
min-height: ${theme.gridUnit * 8};
box-shadow: none;
border-width: 0px;
border-style: none;
border-color: transparent;
margin-left: 9px;
margin-top: 10px;
&:hover {
background-color: ${mix(
0.1,
theme.colors.primary.base,
theme.colors.primary.light4,
)};
color: ${theme.colors.primary.dark1};
}
`}
`;
export const StyledExtentTag = styled(ExtentTag)`
${() => css`
margin-left: 9px;
`}
`;
export const StyledControlFormItem = styled(ControlFormItem)`
${({ theme }) => css`
border-radius: ${theme.borderRadius}px;
`}
`;
export const MapViewControl: FC<MapViewConfigsControlProps> = ({
value,
onChange = () => {},
name,
label,
description,
renderTrigger,
hovered,
validationErrors,
}) => {
const [popoverVisible, setPopoverVisible] = useState<boolean>(false);
const onTagClick = () => {
setPopoverVisible(!popoverVisible);
};
const isCustomMode = () => value?.mode === 'CUSTOM';
const onModeChange = (newValue: 'FIT_DATA' | 'CUSTOM') => {
if (!value) return;
const changedValue: MapViewConfigs = {
...value,
mode: newValue,
};
if (newValue === 'FIT_DATA') {
setPopoverVisible(false);
}
onChange(changedValue);
};
const onButtonClick = () => {
if (!value) return;
const changedValue: MapViewConfigs = {
...value,
fixedLatitude: value?.latitude,
fixedLongitude: value?.longitude,
fixedZoom: value?.zoom,
};
onChange(changedValue);
};
const onSaveClick = (newValue: MapViewConfigs) => {
setPopoverVisible(false);
onChange(newValue);
};
const onCloseClick = () => {
setPopoverVisible(false);
};
const popoverTitle = t('Extent');
const modeNameFitData = t('FIT DATA');
const modeNameCustom = t('CUSTOM');
const extentButtonText = t('Use current extent');
const controlHeaderProps = {
name,
label,
description,
renderTrigger,
hovered,
validationErrors,
};
return (
<div>
<ControlHeader {...controlHeaderProps} />
<StyledControlFormItem
controlType="RadioButtonControl"
label=""
description=""
name="mode"
options={[
['FIT_DATA', modeNameFitData],
['CUSTOM', modeNameCustom],
]}
value={value ? value.mode : undefined}
onChange={onModeChange}
/>
{isCustomMode() && value && (
<StyledExtentTag onClick={onTagClick} value={value} />
)}
{isCustomMode() && value && (
<Popover
visible={popoverVisible}
trigger="click"
title={popoverTitle}
placement="right"
overlayStyle={{
maxWidth: '400px',
maxHeight: '700px',
overflowY: 'auto',
}}
content={
<MapViewPopoverContent
onClose={onCloseClick}
onSave={onSaveClick}
mapViewConf={value}
/>
}
/>
)}
<br />
{isCustomMode() && (
<StyledExtentButton onClick={onButtonClick} size="small">
{extentButtonText}
</StyledExtentButton>
)}
</div>
);
};
export default MapViewControl;

View File

@@ -0,0 +1,200 @@
/**
* 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 { css, styled, t } from '@superset-ui/core';
import { Button, Form } from 'antd';
import { FC, useEffect, useState } from 'react';
import { mix } from 'polished';
import { MapViewConfigs, MapViewPopoverContentProps } from './types';
import { ControlFormItem } from '../ColumnConfigControl/ControlForm';
export const StyledButtonContainer = styled.div`
display: flex;
margin: 8px;
`;
export const StyledCloseButton = styled(Button)`
${({ theme }) => css`
flex: 1;
margin-right: 4px;
line-height: 1.5715;
border-radius: ${theme.borderRadius}px;
background-color: ${theme.colors.primary.light4};
color: ${theme.colors.primary.dark1};
font-size: ${theme.typography.sizes.s}px;
font-weight: ${theme.typography.weights.bold};
text-transform: uppercase;
min-width: ${theme.gridUnit * 36};
min-height: ${theme.gridUnit * 8};
box-shadow: none;
border-width: 0px;
border-style: none;
border-color: transparent;
&:hover {
background-color: ${mix(
0.1,
theme.colors.primary.base,
theme.colors.primary.light4,
)};
color: ${theme.colors.primary.dark1};
}
`}
`;
export const StyledControlNumberFormItem = styled(ControlFormItem)`
${({ theme }) => css`
border-radius: ${theme.borderRadius}px;
width: 100%;
`}
`;
export const StyledSaveButton = styled(Button)`
${({ theme }) => css`
flex: 1;
margin-left: 4px;
line-height: 1.5715;
border-radius: ${theme.borderRadius}px;
background-color: ${theme.colors.primary.base};
color: ${theme.colors.grayscale.light5};
font-size: ${theme.typography.sizes.s}px;
font-weight: ${theme.typography.weights.bold};
text-transform: uppercase;
min-width: ${theme.gridUnit * 36};
min-height: ${theme.gridUnit * 8};
box-shadow: none;
border-width: 0px;
border-style: none;
border-color: transparent;
&:hover {
background-color: ${theme.colors.primary.dark1};
}
`}
`;
export const MapViewPopoverContent: FC<MapViewPopoverContentProps> = ({
onClose = () => {},
onSave = () => {},
mapViewConf,
}) => {
// This is needed to force mounting the form every time
// we get a new layerConf prop. Otherwise the input fields
// will not be updated properly, since ControlFormItem only
// recognises the `value` property once and then handles the
// values in its on state. Remounting creates a new component
// and thereby starts with a fresh state.
const [formKey, setFormKey] = useState<number>(0);
const [currentMapViewConf, setCurrentMapViewConf] =
useState<MapViewConfigs>(mapViewConf);
useEffect(() => {
setFormKey(oldFormKey => oldFormKey + 1);
setCurrentMapViewConf({ ...mapViewConf });
}, [mapViewConf]);
const onCloseClick = () => {
// reset form
setFormKey(oldFormKey => oldFormKey + 1);
setCurrentMapViewConf({ ...mapViewConf });
onClose();
};
const onSaveClick = () => {
onSave(currentMapViewConf);
};
const onZoomChange = (zoom: number) => {
setCurrentMapViewConf({
...currentMapViewConf,
fixedZoom: zoom,
});
};
const onLatitudeChange = (latitude: number) => {
setCurrentMapViewConf({
...currentMapViewConf,
fixedLatitude: latitude,
});
};
const onLongitudeChange = (longitude: number) => {
setCurrentMapViewConf({
...currentMapViewConf,
fixedLongitude: longitude,
});
};
const zoomLabel = t('Zoom');
const latitudeLabel = t('Latitude');
const longitudeLabel = t('Longitude');
const closeButtonText = t('close');
const saveButtonText = t('save');
return (
<div>
<Form key={JSON.stringify(formKey)}>
<StyledControlNumberFormItem
controlType="InputNumber"
label={zoomLabel}
value={currentMapViewConf ? currentMapViewConf.fixedZoom : undefined}
name="zoom"
description=""
min={0}
max={28}
step={1}
onChange={onZoomChange}
/>
<StyledControlNumberFormItem
controlType="InputNumber"
label={latitudeLabel}
value={
currentMapViewConf ? currentMapViewConf.fixedLatitude : undefined
}
name="latitude"
description=""
onChange={onLatitudeChange}
min={-90}
max={90}
/>
<StyledControlNumberFormItem
controlType="InputNumber"
label={longitudeLabel}
value={
currentMapViewConf ? currentMapViewConf.fixedLongitude : undefined
}
name="longitude"
description=""
onChange={onLongitudeChange}
min={-180}
max={180}
/>
<StyledButtonContainer>
<StyledCloseButton type="default" onClick={onCloseClick}>
{closeButtonText}
</StyledCloseButton>
<StyledSaveButton type="primary" onClick={onSaveClick}>
{saveButtonText}
</StyledSaveButton>
</StyledButtonContainer>
</Form>
</div>
);
};
export default MapViewPopoverContent;

View File

@@ -0,0 +1,43 @@
/**
* 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 { ControlComponentProps } from '@superset-ui/chart-controls';
export type MapViewConfigs = {
mode: 'FIT_DATA' | 'CUSTOM';
zoom: number;
latitude: number;
longitude: number;
fixedZoom: number;
fixedLatitude: number;
fixedLongitude: number;
};
export type MapViewConfigsControlProps = ControlComponentProps<MapViewConfigs>;
export interface MapViewPopoverContentProps {
onClose: () => void;
onSave: (currentMapViewConf: MapViewConfigs) => void;
mapViewConf: MapViewConfigs;
}
export interface ExtentTagProps {
value: MapViewConfigs;
onClick: () => void;
className?: string;
}

View File

@@ -31,7 +31,10 @@ interface SelectAsyncControlProps extends SelectAsyncProps {
ariaLabel?: string;
dataEndpoint: string;
default?: SelectValue;
mutator?: (response: Record<string, any>) => SelectOptionsType;
mutator?: (
response: Record<string, any>,
value: SelectValue | undefined,
) => SelectOptionsType;
multi?: boolean;
onChange: (val: SelectValue) => void;
// ControlHeader related props
@@ -57,6 +60,7 @@ const SelectAsyncControl = ({
...props
}: SelectAsyncControlProps) => {
const [options, setOptions] = useState<SelectOptionsType>([]);
const [loaded, setLoaded] = useState<Boolean>(false);
const handleOnChange = (val: SelectValue) => {
let onChangeVal = val;
@@ -92,12 +96,20 @@ const SelectAsyncControl = ({
endpoint: dataEndpoint,
})
.then(response => {
const data = mutator ? mutator(response.json) : response.json.result;
const data = mutator
? mutator(response.json, value)
: response.json.result;
setOptions(data);
})
.catch(onError);
loadOptions();
}, [addDangerToast, dataEndpoint, mutator]);
.catch(onError)
.finally(() => {
setLoaded(true);
});
if (!loaded) {
loadOptions();
}
}, [addDangerToast, dataEndpoint, mutator, value, loaded]);
return (
<Select

View File

@@ -0,0 +1,251 @@
/**
* 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 { ControlHeader } from '@superset-ui/chart-controls';
import { css, styled, t } from '@superset-ui/core';
import { Form, Tag } from 'antd';
import { FC, useState } from 'react';
import { isZoomConfigsLinear, isZoomConfigsExp } from './typeguards';
import { ZoomConfigs, ZoomConfigsControlProps } from './types';
import {
computeConfigValues,
toFixedConfig,
toLinearConfig,
toExpConfig,
} from './zoomUtil';
import ZoomConfigsChart from './ZoomConfigsChart';
import { ControlFormItem } from '../ColumnConfigControl/ControlForm';
export const StyledControlFormItem = styled(ControlFormItem)`
${({ theme }) => css`
border-radius: ${theme.borderRadius}px;
`}
`;
export const ZoomConfigControl: FC<ZoomConfigsControlProps> = ({
value,
onChange = () => {},
name,
label,
description,
renderTrigger,
hovered,
validationErrors,
}) => {
const initBaseWidth = value ? value.configs.width : 0;
const initBaseHeight = value ? value.configs.height : 0;
const initBaseSlope =
value?.configs.slope !== undefined ? value.configs.slope : 0;
const initBaseExponent =
value?.configs.exponent !== undefined ? value.configs.exponent : 0;
const [baseWidth, setBaseWidth] = useState<number>(initBaseWidth);
const [baseHeight, setBaseHeight] = useState<number>(initBaseHeight);
const [baseSlope, setBaseSlope] = useState<number>(initBaseSlope);
const [baseExponent, setBaseExponent] = useState<number>(initBaseExponent);
const onChartChange = (newConfig: ZoomConfigs) => {
onChange(newConfig);
};
const onBaseWidthChange = (width: number) => {
console.log('now in onbasewidthcahnge');
setBaseWidth(width);
if (!value) {
return;
}
const newValue = { ...value };
newValue.configs.width = width;
newValue.values = computeConfigValues(newValue);
onChange(newValue);
};
const onBaseHeightChange = (height: number) => {
setBaseHeight(height);
if (!value) {
return;
}
const newValue = { ...value };
newValue.configs.height = height;
newValue.values = computeConfigValues(newValue);
onChange(newValue);
};
const onBaseSlopeChange = (slope: number) => {
setBaseSlope(slope);
if (value && isZoomConfigsLinear(value)) {
const newValue = { ...value };
newValue.configs.slope = slope;
newValue.values = computeConfigValues(newValue);
onChange(newValue);
}
};
const onBaseExponentChange = (exponent: number) => {
setBaseExponent(exponent);
if (value && isZoomConfigsExp(value)) {
const newValue = { ...value };
newValue.configs.exponent = exponent;
newValue.values = computeConfigValues(newValue);
onChange(newValue);
}
};
const onShapeChange = (shape: ZoomConfigs['type']) => {
if (!value) return;
const baseValues = {
width: baseWidth,
height: baseHeight,
slope: baseSlope,
exponent: baseExponent,
zoom: value?.configs.zoom,
};
switch (shape) {
case 'FIXED': {
const newFixedConfig = toFixedConfig(baseValues);
onChange(newFixedConfig);
break;
}
case 'LINEAR': {
const newLinearConfig = toLinearConfig(baseValues);
onChange(newLinearConfig);
break;
}
case 'EXP': {
const newLogConfig = toExpConfig(baseValues);
onChange(newLogConfig);
break;
}
default:
break;
}
};
const controlHeaderProps = {
name,
label,
description,
renderTrigger,
hovered,
validationErrors,
};
const shapeLabel = t('Shape');
const shapeDescription = t(
'Select shape for computing values. "FIXED" sets all zoom levels to the same size. "LINEAR" increases sizes linearly based on specified slope. "EXP" increases sizes exponentially based on specified exponent',
);
const baseWidthLabel = t('Base width');
const baseWidthDescription = t(
'The width of the current zoom level to compute all widths from',
);
const baseHeightLabel = t('Base height');
const baseHeightDescription = t(
'The height of the current zoom level to compute all heights from',
);
const baseSlopeLabel = t('Base slope');
const baseSlopeDescription = t(
'The slope to compute all sizes from. "LINEAR" only',
);
const baseExponentLabel = t('Base exponent');
const baseExponentDescription = t(
'The exponent to compute all sizes from. "EXP" only',
);
return (
<div>
<ControlHeader {...controlHeaderProps} />
<Form>
<StyledControlFormItem
controlType="RadioButtonControl"
label={shapeLabel}
description={shapeDescription}
options={[
['FIXED', 'FIXED'],
['LINEAR', 'LINEAR'],
['EXP', 'EXP'],
]}
value={value ? value.type : undefined}
name="shape"
onChange={onShapeChange}
/>
<StyledControlFormItem
controlType="Slider"
label={baseWidthLabel}
description={baseWidthDescription}
value={baseWidth}
name="baseWidth"
// @ts-ignore
onAfterChange={onBaseWidthChange}
step={1}
min={0}
max={500}
/>
<StyledControlFormItem
controlType="Slider"
label={baseHeightLabel}
description={baseHeightDescription}
value={baseHeight}
name="baseHeight"
// @ts-ignore
onAfterChange={onBaseHeightChange}
step={1}
min={0}
max={500}
/>
<StyledControlFormItem
controlType="Slider"
label={baseSlopeLabel}
description={baseSlopeDescription}
value={baseSlope}
name="slope"
// @ts-ignore
onAfterChange={onBaseSlopeChange}
disabled={!!(value && !isZoomConfigsLinear(value))}
step={1}
min={0}
max={100}
/>
<StyledControlFormItem
controlType="Slider"
label={baseExponentLabel}
description={baseExponentDescription}
value={baseExponent}
name="exponent"
// @ts-ignore
onAfterChange={onBaseExponentChange}
disabled={!!(value && !isZoomConfigsExp(value))}
step={0.2}
min={0}
max={3}
/>
<Tag>Current Zoom: {value?.configs.zoom}</Tag>
</Form>
<ZoomConfigsChart
name="zoomlevels"
value={value}
onChange={onChartChange}
/>
</div>
);
};
export default ZoomConfigControl;

View File

@@ -0,0 +1,183 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import * as echarts from 'echarts';
import { createRef, FC, useEffect } from 'react';
import { ZoomConfigsChartProps } from './types';
import {
createDragGraphicOptions,
dataToZoomConfigs,
MAX_ZOOM_LEVEL,
MIN_ZOOM_LEVEL,
zoomConfigsToData,
} from './zoomUtil';
export const ZoomConfigsChart: FC<ZoomConfigsChartProps> = ({
value,
onChange = () => {},
}) => {
const ref = createRef<HTMLDivElement>();
useEffect(() => {
if (!ref.current) {
return undefined;
}
// TODO check if this can be applied here
if (value === null || value === undefined) {
return undefined;
}
let timer: number;
const barWidth = 15;
const data = zoomConfigsToData(value.values);
const chart = echarts.init(ref.current);
const option = {
xAxis: {
min: 0,
name: t('Size in pixels'),
nameLocation: 'center',
nameGap: 25,
},
yAxis: {
type: 'category',
min: MIN_ZOOM_LEVEL,
max: MAX_ZOOM_LEVEL,
name: t('Zoom level'),
nameLocation: 'center',
nameRotate: 90,
nameGap: 25,
},
dataset: {
dimensions: ['width', 'height', 'zoom'],
source: data,
},
grid: {
top: 12,
left: 40,
},
series: [
{
id: 'width',
name: 'width',
type: 'bar',
animation: false,
showBackground: true,
barWidth,
barGap: '0%',
label: {
show: true,
formatter: '{a}: {@width}',
},
encode: {
x: 'width',
y: 'zoom',
},
},
{
id: 'height',
name: 'height',
type: 'bar',
animation: false,
showBackground: true,
barWidth,
barGap: '0%',
label: {
show: true,
formatter: '{a}: {@height}',
},
encode: {
x: 'height',
y: 'zoom',
},
},
],
};
chart.setOption(option);
const onDrag = function (
this: any,
dataIndex: number | undefined,
itemIndex: number,
) {
if (dataIndex === undefined) {
return;
}
// eslint-disable-next-line react/no-this-in-sfc
const newPosition = chart.convertFromPixel('grid', [this.x, this.y]);
if (typeof newPosition === 'number') {
return;
}
const roundedPosition = Math.round(newPosition[0]);
const newRoundedPosition = roundedPosition < 0 ? 0 : roundedPosition;
data[dataIndex][itemIndex] = newRoundedPosition;
chart.setOption({
dataset: {
source: data,
},
});
if (timer !== undefined) {
clearTimeout(timer);
}
timer = window.setTimeout(() => {
const newValues = dataToZoomConfigs(data);
onChange({ ...value, values: newValues });
}, 200);
};
const onWidthDrag = function (this: any, dataIndex: number | undefined) {
onDrag.call(this, dataIndex, 0);
};
const onHeightDrag = function (this: any, dataIndex: number | undefined) {
onDrag.call(this, dataIndex, 1);
};
// TODO listen to resize event and redraw chart
// TODO rearrange the draghandlers when the chart range changes
chart.setOption({
graphic: createDragGraphicOptions({
data,
onWidthDrag,
onHeightDrag,
barWidth,
chart,
}),
});
// chart.on('click', 'series', (params) => {
// const clickedData: number[] = params.data as number[];
// const zoomLevel: number = clickedData[2];
// // TODO we have to set a flag on value that indicates, which zoomLevel should be active
// // TODO maybe it's better to add a callback to the map that triggers when the zoom
// // in the map changes. This can then be displayed on the zoom chart.
// });
return () => {
clearTimeout(timer);
};
}, [value]);
return <div ref={ref} style={{ height: '1300px', width: '100%' }} />;
};
export default ZoomConfigsChart;

View File

@@ -0,0 +1,36 @@
/**
* 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 {
ZoomConfigs,
ZoomConfigsFixed,
ZoomConfigsLinear,
ZoomConfigsExp,
} from './types';
export const isZoomConfigsFixed = (
zoomConfigs: ZoomConfigs,
): zoomConfigs is ZoomConfigsFixed => zoomConfigs.type === 'FIXED';
export const isZoomConfigsLinear = (
zoomConfigs: ZoomConfigs,
): zoomConfigs is ZoomConfigsLinear => zoomConfigs.type === 'LINEAR';
export const isZoomConfigsExp = (
zoomConfigs: ZoomConfigs,
): zoomConfigs is ZoomConfigsExp => zoomConfigs.type === 'EXP';

View File

@@ -0,0 +1,93 @@
/**
* 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 { ControlComponentProps } from '@superset-ui/chart-controls';
export type ZoomConfigs = ZoomConfigsFixed | ZoomConfigsLinear | ZoomConfigsExp;
export type ChartSizeValues = {
[index: number]: { width: number; height: number };
};
export interface ZoomConfigsBase {
type: string;
configs: {
zoom: number;
width: number;
height: number;
slope?: number;
exponent?: number;
};
values: ChartSizeValues;
}
export interface ZoomConfigsFixed extends ZoomConfigsBase {
type: 'FIXED';
}
export interface ZoomConfigsLinear extends ZoomConfigsBase {
type: 'LINEAR';
configs: {
zoom: number;
width: number;
height: number;
slope: number;
exponent?: number;
};
}
export interface ZoomConfigsExp extends ZoomConfigsBase {
type: 'EXP';
configs: {
zoom: number;
width: number;
height: number;
slope?: number;
exponent: number;
};
}
export type ZoomConfigsControlProps = ControlComponentProps<ZoomConfigs>;
export interface CreateDragGraphicOptions {
data: number[][];
onWidthDrag: (...arg: any[]) => any;
onHeightDrag: (...args: any[]) => any;
barWidth: number;
chart: any;
}
export interface CreateDragGraphicOption {
dataItem: number[];
dataItemIndex: number;
dataIndex: number;
onDrag: (...arg: any[]) => any;
barWidth: number;
chart: any;
add: boolean;
}
export interface GetDragGraphicPositionOptions {
chart: any;
x: number;
y: number;
barWidth: number;
add: boolean;
}
export type ZoomConfigsChartProps = ZoomConfigsControlProps;

View File

@@ -0,0 +1,199 @@
/**
* 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 { ZoomConfigs } from './types';
import {
computeConfigValues,
MAX_ZOOM_LEVEL,
MIN_ZOOM_LEVEL,
toExpConfig,
toFixedConfig,
toLinearConfig,
zoomConfigsToData,
} from './zoomUtil';
const zoomConfigValues = {
...Array.from({ length: MAX_ZOOM_LEVEL - MIN_ZOOM_LEVEL + 1 }, () => ({
width: 100,
height: 100,
})),
};
describe('zoomUtil', () => {
describe('computeConfigValues', () => {
it('computes fixed values', () => {
const height = 100;
const width = 100;
const zoomConfigs: ZoomConfigs = {
type: 'FIXED',
values: {},
configs: {
zoom: 2,
width,
height,
},
};
const result = computeConfigValues(zoomConfigs);
expect(Object.keys(result).length).toEqual(
Object.keys(zoomConfigValues).length,
);
expect(result[4]).toEqual({
width,
height,
});
});
it('computes linear values', () => {
const height = 100;
const width = 100;
const zoomConfigs: ZoomConfigs = {
type: 'LINEAR',
values: {},
configs: {
zoom: 2,
width,
height,
slope: 2,
},
};
const result = computeConfigValues(zoomConfigs);
expect(Object.keys(result).length).toEqual(
Object.keys(zoomConfigValues).length,
);
expect(result[4]).toEqual({
width: 104,
height: 104,
});
});
it('computes exponential values', () => {
const height = 100;
const width = 100;
const zoomConfigs: ZoomConfigs = {
type: 'EXP',
values: {},
configs: {
zoom: 2,
width,
height,
exponent: 1.6,
},
};
const result = computeConfigValues(zoomConfigs);
expect(Object.keys(result).length).toEqual(
Object.keys(zoomConfigValues).length,
);
expect(result[4]).toEqual({
width: 119,
height: 119,
});
});
});
describe('zoomConfigsToData', () => {
it('returns correct output', () => {
const result = zoomConfigsToData(zoomConfigValues);
expect(result.length).toEqual(Object.keys(zoomConfigValues).length);
expect(result[12]).toEqual([100, 100, 12]);
});
});
describe('toFixedConfig', () => {
const configs: ZoomConfigs['configs'] = {
width: 100,
height: 100,
zoom: 5,
};
const result = toFixedConfig(configs);
it('has correct type', () => {
expect(result.type).toEqual('FIXED');
});
it('returns correct result', () => {
expect(result.values[4]).toEqual({
width: 100,
height: 100,
});
expect(result.values[6]).toEqual({
width: 100,
height: 100,
});
});
});
describe('toLinearConfig', () => {
const configs: ZoomConfigs['configs'] = {
width: 100,
height: 100,
zoom: 5,
slope: 2,
};
const result = toLinearConfig(configs);
it('has correct type', () => {
expect(result.type).toEqual('LINEAR');
});
it('returns correct result', () => {
expect(result.values[4]).toEqual({
width: 98,
height: 98,
});
expect(result.values[6]).toEqual({
width: 102,
height: 102,
});
});
});
describe('toExpConfig', () => {
const configs: ZoomConfigs['configs'] = {
width: 100,
height: 100,
zoom: 5,
exponent: 1.5,
};
// @ts-ignore
const result = toExpConfig(configs);
it('has correct type', () => {
expect(result.type).toEqual('EXP');
});
it('returns correct result', () => {
expect(result.values[4]).toEqual({
width: 93,
height: 93,
});
expect(result.values[6]).toEqual({
width: 107,
height: 107,
});
});
});
});

View File

@@ -0,0 +1,348 @@
/**
* 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 * as echarts from 'echarts';
import { isZoomConfigsFixed, isZoomConfigsLinear } from './typeguards';
import {
CreateDragGraphicOption,
CreateDragGraphicOptions,
GetDragGraphicPositionOptions,
ZoomConfigs,
ZoomConfigsFixed,
ZoomConfigsLinear,
ZoomConfigsExp,
} from './types';
/**
* Compute the position for a drag graphic.
*
* @param param0 configuration
* @param param0.chart The eChart instance.
* @param param0.x The x value of the data item.
* @param param0.y The y value of the data item.
* @param param0.barWidth The width of the bar.
* @param param0.add True, if barWidth should be added. False, if barWidth should be subtracted.
* @returns
*/
export const getDragGraphicPosition = ({
chart,
x,
y,
barWidth,
add,
}: GetDragGraphicPositionOptions) => {
const valuePosition = chart.convertToPixel('grid', [x, y]);
const xPos = Math.round(valuePosition[0]);
let yPos = valuePosition[1] - barWidth / 2;
if (add) {
yPos = valuePosition[1] + barWidth / 2;
}
return [xPos, yPos];
};
/**
* Create a single drag graphic with drag handler.
* @param param0 configuration
* @param param0.dataItem The data item to create the graphic for.
* @param param0.dataItemIndex The index of the height/width value in the item.
* @param param0.dataIndex The index of the dataItem in the data.
* @param param0.onDrag Callback for dragging the bar.
* @param param0.barWidth The width of the bar.
* @param param0.chart The eChart instance.
* @param param0.add True, if barWidth should be added for positioning. False, if barWidth should be subtracted.
* @returns eChart Option for a drag graphic.
*/
export const createDragGraphicOption = ({
dataItem,
dataItemIndex,
dataIndex,
onDrag,
barWidth,
chart,
add,
}: CreateDragGraphicOption) => {
const position = getDragGraphicPosition({
chart,
x: dataItem[dataItemIndex],
y: dataItem[2],
barWidth,
add,
});
return {
type: 'circle',
shape: {
// The radius of the circle.
r: barWidth / 4,
},
x: position[0],
y: position[1],
invisible: false,
style: {
// eslint-disable-next-line theme-colors/no-literal-colors
fill: '#ffffff',
// eslint-disable-next-line theme-colors/no-literal-colors
stroke: '#aaa',
},
cursor: 'ew-resize',
draggable: 'horizontal',
// Give a big z value, which makes the circle cover the symbol
// in bar series.
z: 100,
// Util method `echarts.util.curry` is used here to generate a
// new function the same as `onDrag`, except that the
// first parameter is fixed to be the `dataIndex` here.
ondrag: echarts.util.curry(onDrag, dataIndex),
};
};
/**
* Create a drag graphic with dragHandler for each bar.
*
* @param param0 configuration
* @param param0.data The eChart data.
* @param param0.onWidthDrag Callback for dragging width bars.
* @param param0.onHeightDrag Callback for dragging height bars.
* @param param0.barWidth The width of a single bar.
* @param param0.chart The eChart instance.
* @returns List of eChart options for the drag graphics.
*/
export const createDragGraphicOptions = ({
data,
onWidthDrag,
onHeightDrag,
barWidth,
chart,
}: CreateDragGraphicOptions) => {
const graphics: any[] = [];
data.forEach((dataItem: number[], dataIndex: number) => {
const widthGraphic = createDragGraphicOption({
dataItem,
dataIndex,
barWidth,
chart,
dataItemIndex: 0,
onDrag: onWidthDrag,
add: false,
});
graphics.push(widthGraphic);
const heightGraphic = createDragGraphicOption({
dataItem,
dataIndex,
barWidth,
chart,
dataItemIndex: 1,
onDrag: onHeightDrag,
add: true,
});
graphics.push(heightGraphic);
});
return graphics;
};
/**
* Convert ZoomConfigs to eChart data.
*
* @param zoomConfigs The config to convert.
* @returns eChart data representing the zoom configs.
*/
export const zoomConfigsToData = (zoomConfigs: ZoomConfigs['values']) =>
Object.keys(zoomConfigs)
.map((k: string) => parseInt(k, 10))
.map((k: number) => [zoomConfigs[k].width, zoomConfigs[k].height, k]);
/**
* Convert eChart data to ZoomConfigs.
*
* @param data The eChart data to convert.
* @returns ZoomConfigs representing the eChart data.
*/
export const dataToZoomConfigs = (data: number[][]): ZoomConfigs['values'] =>
data.reduce((prev: ZoomConfigs['values'], cur: number[]) => {
// eslint-disable-next-line no-param-reassign
prev[cur[2]] = { width: cur[0], height: cur[1] };
return prev;
}, {});
export const MAX_ZOOM_LEVEL = 28;
export const MIN_ZOOM_LEVEL = 0;
/**
* Compute values for all zoom levels with fixed shape.
*
* @param zoomConfigsFixed The config to base the computation upon.
* @returns The computed values for each zoom level.
*/
const computeFixedConfigValues = (zoomConfigsFixed: ZoomConfigsFixed) => {
const values: ZoomConfigsFixed['values'] = {};
for (let i = MIN_ZOOM_LEVEL; i <= MAX_ZOOM_LEVEL; i += 1) {
const width = Math.round(zoomConfigsFixed.configs.width);
const height = Math.round(zoomConfigsFixed.configs.height);
values[i] = {
width: width > 0 ? width : 0,
height: height > 0 ? height : 0,
};
}
return values;
};
/**
* Compute values for all zoom levels with linear shape.
*
* @param zoomConfigsLinear The config to base the computation upon.
* @returns The computed values for each zoom level.
*/
const computeLinearConfigValues = (zoomConfigsLinear: ZoomConfigsLinear) => {
const values: ZoomConfigsLinear['values'] = {};
for (let i = MIN_ZOOM_LEVEL; i <= MAX_ZOOM_LEVEL; i += 1) {
const aspectRatio =
zoomConfigsLinear.configs.height / zoomConfigsLinear.configs.width;
const width = Math.round(
zoomConfigsLinear.configs.width -
(zoomConfigsLinear.configs.zoom - i) * zoomConfigsLinear.configs.slope,
);
const height = Math.round(aspectRatio * width);
values[i] = {
width: width > 0 ? width : 0,
height: height > 0 ? height : 0,
};
}
return values;
};
/**
* Compute values for all zoom levels with exponential shape.
*
* @param zoomConfigsExp The config to base the computation upon.
* @returns The computed values for each zoom level.
*/
const computeExpConfigValues = (zoomConfigsExp: ZoomConfigsExp) => {
const values: ZoomConfigsExp['values'] = {};
const x = Math.pow(
zoomConfigsExp.configs.width,
1 / zoomConfigsExp.configs.exponent,
);
for (let i = MIN_ZOOM_LEVEL; i <= MAX_ZOOM_LEVEL; i += 1) {
const aspectRatio =
zoomConfigsExp.configs.height / zoomConfigsExp.configs.width;
const width = Math.round(
Math.pow(
x - (zoomConfigsExp.configs.zoom - i),
zoomConfigsExp.configs.exponent,
),
);
const height = Math.round(aspectRatio * width);
values[i] = {
width: width > 0 ? width : 0,
height: height > 0 ? height : 0,
};
}
return values;
};
/**
* Compute values for all zoom levels.
*
* @param zoomConfigs The config to base the computation upon.
* @returns The computed values for each zoom level.
*/
export const computeConfigValues = (zoomConfigs: ZoomConfigs) => {
if (isZoomConfigsFixed(zoomConfigs)) {
return computeFixedConfigValues(zoomConfigs);
}
if (isZoomConfigsLinear(zoomConfigs)) {
return computeLinearConfigValues(zoomConfigs);
}
return computeExpConfigValues(zoomConfigs);
};
/**
* Convert ZoomConfigs to ZoomConfigsFixed.
*
* @param baseConfig The base config.
* @returns The converted config.
*/
export const toFixedConfig = (
baseConfig: ZoomConfigsFixed['configs'],
): ZoomConfigsFixed => {
const zoomConfigFixed: ZoomConfigsFixed = {
type: 'FIXED',
configs: {
zoom: baseConfig.zoom,
width: baseConfig.width,
height: baseConfig.height,
},
values: {},
};
zoomConfigFixed.values = computeFixedConfigValues(zoomConfigFixed);
return zoomConfigFixed;
};
/**
* Convert ZoomConfigs to ZoomConfigsLinear.
*
* @param baseConfig The base config.
* @returns The converted config.
*/
export const toLinearConfig = (
baseConfig: ZoomConfigsFixed['configs'],
): ZoomConfigsLinear => {
const zoomConfigsLinear: ZoomConfigsLinear = {
type: 'LINEAR',
configs: {
zoom: baseConfig.zoom,
width: baseConfig.width,
height: baseConfig.height,
slope: baseConfig.slope,
},
values: {},
} as ZoomConfigsLinear;
zoomConfigsLinear.values = computeLinearConfigValues(zoomConfigsLinear);
return zoomConfigsLinear;
};
/**
* Convert ZoomConfigs to ZoomConfigsExp.
*
* @param baseConfig The base config.
* @returns The converted config.
*/
export const toExpConfig = (
baseConfig: ZoomConfigsExp['configs'],
): ZoomConfigsExp => {
const zoomConfigExp: ZoomConfigsExp = {
type: 'EXP',
configs: {
zoom: baseConfig.zoom,
width: baseConfig.width,
height: baseConfig.height,
exponent: baseConfig.exponent,
},
values: {},
} as ZoomConfigsExp;
zoomConfigExp.values = computeExpConfigValues(zoomConfigExp);
return zoomConfigExp;
};

View File

@@ -50,6 +50,9 @@ import XAxisSortControl from './XAxisSortControl';
import CurrencyControl from './CurrencyControl';
import ColumnConfigControl from './ColumnConfigControl';
import { ComparisonRangeLabel } from './ComparisonRangeLabel';
import LayerConfigsControl from './LayerConfigsControl/LayerConfigsControl';
import MapViewControl from './MapViewControl/MapViewControl';
import ZoomConfigControl from './ZoomConfigControl/ZoomConfigControl';
const controlMap = {
AnnotationLayerControl,
@@ -68,6 +71,8 @@ const controlMap = {
DndMetricSelect,
FixedOrMetricControl,
HiddenControl,
LayerConfigsControl,
MapViewControl,
SelectAsyncControl,
SelectControl,
SliderControl,
@@ -84,6 +89,7 @@ const controlMap = {
ContourControl,
ComparisonRangeLabel,
TimeOffsetControl,
ZoomConfigControl,
...sharedControlComponents,
};
export default controlMap;

View File

@@ -49,6 +49,7 @@ import {
TimePivotChartPlugin,
} from '@superset-ui/legacy-preset-chart-nvd3';
import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl';
import { CartodiagramPlugin } from '@superset-ui/plugin-chart-cartodiagram';
import {
BigNumberChartPlugin,
BigNumberTotalChartPlugin,
@@ -186,6 +187,19 @@ export default class MainPreset extends Preset {
new EchartsSunburstChartPlugin().configure({ key: VizType.Sunburst }),
new HandlebarsChartPlugin().configure({ key: VizType.Handlebars }),
new EchartsBubbleChartPlugin().configure({ key: VizType.Bubble }),
new CartodiagramPlugin({
defaultLayers: [
{
type: 'WMS',
version: '1.3.0',
url: 'https://ows.terrestris.de/osm-gray/service',
layersParam: 'OSM-WMS',
title: 'OpenStreetMap',
attribution:
'© Map data from <a href="openstreetmap.org/copyright">OpenStreetMap</a>. Service provided by <a href="https://www.terrestris.de">terrestris GmbH & Co. KG</a>',
},
],
}).configure({ key: VizType.Cartodiagram }),
...experimentalPlugins,
],
});

View File

@@ -1595,6 +1595,7 @@ TALISMAN_CONFIG = {
"https://apachesuperset.gateway.scarf.sh",
"https://static.scarf.sh/",
# "https://avatars.slack-edge.com", # Uncomment when SLACK_ENABLE_AVATARS is True # noqa: E501
"ows.terrestris.de",
],
"worker-src": ["'self'", "blob:"],
"connect-src": [
@@ -1625,6 +1626,7 @@ TALISMAN_DEV_CONFIG = {
"https://apachesuperset.gateway.scarf.sh",
"https://static.scarf.sh/",
"https://avatars.slack-edge.com",
"ows.terrestris.de",
],
"worker-src": ["'self'", "blob:"],
"connect-src": [