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

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

View File

@@ -0,0 +1,86 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SupersetTheme } from '@superset-ui/core';
import { ChartConfig, ChartConfigFeature } from '../types';
import ChartWrapper from '../components/ChartWrapper';
/**
* Create a chart component for a location.
*
* @param chartVizType The superset visualization type
* @param chartConfigs The chart configurations
* @param chartWidth The chart width
* @param chartHeight The chart height
* @param chartTheme The chart theme
* @returns The chart as React component
*/
export const createChartComponent = (
chartVizType: string,
chartConfig: ChartConfigFeature,
chartWidth: number,
chartHeight: number,
chartTheme: SupersetTheme,
) => (
<ChartWrapper
vizType={chartVizType}
chartConfig={chartConfig}
width={chartWidth}
height={chartHeight}
theme={chartTheme}
/>
);
/**
* Simplifies a chart configuration by removing
* non-serializable properties.
*
* @param config The chart configuration to simplify.
* @returns The simplified chart configuration.
*/
export const simplifyConfig = (config: ChartConfig) => {
const simplifiedConfig: ChartConfig = {
type: config.type,
features: config.features.map(f => ({
type: f.type,
geometry: f.geometry,
properties: Object.keys(f.properties)
.filter(k => k !== 'refs')
.reduce((prev, cur) => ({ ...prev, [cur]: f.properties[cur] }), {}),
})),
};
return simplifiedConfig;
};
/**
* Check if two chart configurations are equal (deep equality).
*
* @param configA The first chart config for comparison.
* @param configB The second chart config for comparison.
* @returns True, if configurations are equal. False otherwise.
*/
export const isChartConfigEqual = (
configA: ChartConfig,
configB: ChartConfig,
) => {
const simplifiedConfigA = simplifyConfig(configA);
const simplifiedConfigB = simplifyConfig(configB);
return (
JSON.stringify(simplifiedConfigA) === JSON.stringify(simplifiedConfigB)
);
};

View File

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

View File

@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Util for geometry related operations.
*/
import GeoJSON from 'ol/format/GeoJSON';
import Feature from 'ol/Feature';
import { Point as OlPoint } from 'ol/geom';
import VectorSource from 'ol/source/Vector';
import { Point as GeoJsonPoint } from 'geojson';
/**
* Extracts the coordinate from a Point GeoJSON in the current map projection.
*
* @param geoJsonPoint The GeoJSON string for the point
*
* @returns The coordinate
*/
export const getProjectedCoordinateFromPointGeoJson = (
geoJsonPoint: GeoJsonPoint,
) => {
const geom: OlPoint = new GeoJSON().readGeometry(geoJsonPoint, {
// TODO: adapt to map projection
featureProjection: 'EPSG:3857',
}) as OlPoint;
return geom.getCoordinates();
};
/**
* Computes the extent for an array of features.
*
* @param features An Array of OpenLayers features
* @returns The OpenLayers extent or undefined
*/
export const getExtentFromFeatures = (features: Feature[]) => {
if (features.length === 0) {
return undefined;
}
const source = new VectorSource();
source.addFeatures(features);
return source.getExtent();
};

View File

@@ -0,0 +1,160 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Util for layer related operations.
*/
import OlParser from 'geostyler-openlayers-parser';
import TileLayer from 'ol/layer/Tile';
import TileWMS from 'ol/source/TileWMS';
import { bbox as bboxStrategy } from 'ol/loadingstrategy';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import XyzSource from 'ol/source/XYZ';
import GeoJSON from 'ol/format/GeoJSON';
import { WmsLayerConf, WfsLayerConf, LayerConf, XyzLayerConf } from '../types';
import { isWfsLayerConf, isWmsLayerConf, isXyzLayerConf } from '../typeguards';
import { isVersionBelow } from './serviceUtil';
/**
* Create a WMS layer.
*
* @param wmsLayerConf The layer configuration
*
* @returns The created WMS layer
*/
export const createWmsLayer = (wmsLayerConf: WmsLayerConf) => {
const { url, layersParam, version, attribution } = wmsLayerConf;
return new TileLayer({
source: new TileWMS({
url,
params: {
LAYERS: layersParam,
VERSION: version,
},
attributions: attribution,
}),
});
};
/**
* Create a XYZ layer.
*
* @param xyzLayerConf The layer configuration
*
* @returns The created XYZ layer
*/
export const createXyzLayer = (xyzLayerConf: XyzLayerConf) => {
const { url, attribution } = xyzLayerConf;
return new TileLayer({
source: new XyzSource({
url,
attributions: attribution,
}),
});
};
/**
* Create a WFS layer.
*
* @param wfsLayerConf The layer configuration
*
* @returns The created WFS layer
*/
export const createWfsLayer = async (wfsLayerConf: WfsLayerConf) => {
const {
url,
typeName,
maxFeatures,
version = '1.1.0',
style,
attribution,
} = wfsLayerConf;
const wfsSource = new VectorSource({
format: new GeoJSON(),
attributions: attribution,
url: extent => {
const requestUrl = new URL(url);
const params = requestUrl.searchParams;
params.append('service', 'wfs');
params.append('request', 'GetFeature');
params.append('outputFormat', 'application/json');
// TODO: make CRS configurable or take it from Ol Map
params.append('srsName', 'EPSG:3857');
params.append('version', version);
let typeNameQuery = 'typeNames';
if (isVersionBelow(version, '2.0.0', 'WFS')) {
typeNameQuery = 'typeName';
}
params.append(typeNameQuery, typeName);
params.append('bbox', extent.join(','));
if (maxFeatures) {
let maxFeaturesQuery = 'count';
if (isVersionBelow(version, '2.0.0', 'WFS')) {
maxFeaturesQuery = 'maxFeatures';
}
params.append(maxFeaturesQuery, maxFeatures.toString());
}
return requestUrl.toString();
},
strategy: bboxStrategy,
});
let writeStyleResult;
if (style) {
const olParser = new OlParser();
writeStyleResult = await olParser.writeStyle(style);
if (writeStyleResult.errors) {
console.warn('Could not create ol-style', writeStyleResult.errors);
return undefined;
}
}
return new VectorLayer({
source: wfsSource,
// @ts-ignore
style: writeStyleResult?.output,
});
};
/**
* Create a layer instance with the provided configuration.
*
* @param layerConf The layer configuration
*
* @returns The created layer
*/
export const createLayer = async (layerConf: LayerConf) => {
let layer;
if (isWmsLayerConf(layerConf)) {
layer = createWmsLayer(layerConf);
} else if (isWfsLayerConf(layerConf)) {
layer = await createWfsLayer(layerConf);
} else if (isXyzLayerConf(layerConf)) {
layer = createXyzLayer(layerConf);
} else {
console.warn('Provided layerconfig is not recognized');
}
return layer;
};

View File

@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Util for map related operations.
*/
import { Map } from 'ol';
import GeoJSON from 'ol/format/GeoJSON';
import { ChartConfig } from '../types';
import { getExtentFromFeatures } from './geometryUtil';
// default map extent of world if no features are found
// TODO: move to generic config file or plugin configuration
// TODO: adapt to CRS other than Web Mercator
const defaultExtent = [-16000000, -7279000, 20500000, 11000000];
/**
* Fits map to the spatial extent of provided charts.
*
* @param olMap The OpenLayers map
* @param chartConfigs The chart configuration
*/
export const fitMapToCharts = (olMap: Map, chartConfigs: ChartConfig) => {
const view = olMap.getView();
const features = new GeoJSON().readFeatures(chartConfigs, {
// TODO: adapt to map projection
featureProjection: 'EPSG:3857',
});
const extent = getExtentFromFeatures(features) || defaultExtent;
view.fit(extent, {
// tested for a desktop size monitor
size: [250, 250],
});
};

View File

@@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Get the available versions of WFS and WMS.
*
* @returns the versions
*/
export const getServiceVersions = () => ({
WMS: ['1.3.0', '1.1.1'],
WFS: ['2.0.2', '2.0.0', '1.1.0'],
});
/**
* Checks if a given version is below the comparer version.
*
* @param version The version to check.
* @param below The version to compare to.
* @param serviceType The service type.
* @returns True, if the version is below comparer version. False, otherwise.
*/
export const isVersionBelow = (
version: string,
below: string,
serviceType: 'WFS' | 'WMS',
) => {
const versions = getServiceVersions()[serviceType];
// versions is ordered from newest to oldest, so we invert the order
// to improve the readability of this function.
versions.reverse();
const versionIdx = versions.indexOf(version);
if (versionIdx === -1) {
// TODO: consider throwing an error instead
return false;
}
const belowIdx = versions.indexOf(below);
if (belowIdx === -1) {
// TODO: consider throwing an error instead
return false;
}
return versionIdx < belowIdx;
};

View File

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

View File

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