mirror of
https://github.com/apache/superset.git
synced 2026-04-18 23:55:00 +00:00
feat(plugin): add plugin-chart-cartodiagram (#25869)
Co-authored-by: Jakob Miksch <jakob@meggsimum.de>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
1918
superset-frontend/package-lock.json
generated
1918
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -26,6 +26,7 @@ export enum VizType {
|
||||
Bubble = 'bubble_v2',
|
||||
Bullet = 'bullet',
|
||||
Calendar = 'cal_heatmap',
|
||||
Cartodiagram = 'cartodiagram',
|
||||
Chord = 'chord',
|
||||
Compare = 'compare',
|
||||
CountryMap = 'country_map',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
210
superset-frontend/plugins/plugin-chart-cartodiagram/src/types.ts
Normal file
210
superset-frontend/plugins/plugin-chart-cartodiagram/src/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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],
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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"
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
]
|
||||
}
|
||||
28
superset-frontend/plugins/plugin-chart-cartodiagram/types/external.d.ts
vendored
Normal file
28
superset-frontend/plugins/plugin-chart-cartodiagram/types/external.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user