mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(plugin): add plugin-chart-cartodiagram (#25869)
Co-authored-by: Jakob Miksch <jakob@meggsimum.de>
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user