diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/src/constants.ts b/superset-frontend/plugins/plugin-chart-cartodiagram/src/constants.ts index 2f356e18c67b..99b78b8de2d9 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/src/constants.ts +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/src/constants.ts @@ -35,6 +35,10 @@ export enum GeometryFormat { export const NULL_STRING = ''; export const SELECTION_LAYER_NAME = 'thematic-selection-layer'; +export const PRESENTATION_LAYER_NAME = 'thematic-presentation-layer'; +export const MASK_LAYER_NAME = 'thematic-mask-layer'; export const LAYER_NAME_PROP = 'layerName'; export const SELECTION_BACKGROUND_OPACITY = 0.5; export const FULL_OPACITY = 1; +export const DEFAULT_MASK_COLOR = { r: 128, g: 128, b: 128, a: 1 }; +export const DEFAULT_MERGED_STROKE_COLOR = { r: 0, g: 0, b: 0, a: 1 }; diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/components/OlChartMap.tsx b/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/components/OlChartMap.tsx index 5f68044901b9..69a351f40ec6 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/components/OlChartMap.tsx +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/components/OlChartMap.tsx @@ -31,13 +31,16 @@ import VectorSource from 'ol/source/Vector'; import GeoJSON from 'ol/format/GeoJSON'; import { dataRecordsToOlFeatures, + fitMapToFeatures, fitMapToData, - fitMapToDataRecords, } from '../../util/mapUtil'; import { + createMaskLayer, createLayer, + createPresentationLayer, createSelectionLayer, getSelectedFeatures, + removeManagedLayer, removeSelectionLayer, setSelectionBackgroundOpacity, } from '../../util/layerUtil'; @@ -48,10 +51,17 @@ import { StyledFeatureTooltip } from './FeatureTooltip'; import { FULL_OPACITY, GeometryFormat, + MASK_LAYER_NAME, + PRESENTATION_LAYER_NAME, SELECTION_BACKGROUND_OPACITY, } from '../../constants'; import { Legend } from './Legend'; -import { getMapExtentPadding } from '../../util/geometryUtil'; +import { + areFeaturesPolygonal, + createMaskFeature, + getMapExtentPadding, + mergePolygonFeatures, +} from '../../util/geometryUtil'; export const OlChartMap = (props: OlChartMapProps) => { const { @@ -79,6 +89,12 @@ export const OlChartMap = (props: OlChartMapProps) => { tooltipTemplate, showLegend, showTooltip, + showAreaMask, + areaMaskOpacity, + areaMaskColor, + mergePolygonEntities, + mergedPolygonStrokeWidth, + mergedPolygonStrokeColor, } = props; const [currentMapView, setCurrentMapView] = useState(mapView); @@ -108,6 +124,25 @@ export const OlChartMap = (props: OlChartMapProps) => { return data; }, [data, geomColumn, geomFormat]); + /** + * Use OL features for WKB/WKT-specific flows that need native OpenLayers + * features repeatedly, such as extent fitting and vector source updates. + * Keep using processedData for filtering and GeoJSON-backed logic. + */ + const dataFeatures = useMemo(() => { + if ( + geomFormat === GeometryFormat.WKB || + geomFormat === GeometryFormat.WKT + ) { + return dataRecordsToOlFeatures( + processedData as DataRecord[], + geomColumn, + geomFormat, + ) as OlFeature[]; + } + return undefined; + }, [processedData, geomColumn, geomFormat]); + /** * Add map to correct DOM element. */ @@ -203,11 +238,9 @@ export const OlChartMap = (props: OlChartMapProps) => { geomFormat === GeometryFormat.WKB || geomFormat === GeometryFormat.WKT ) { - fitMapToDataRecords( + fitMapToFeatures( olMap, - processedData as DataRecord[], - geomColumn, - geomFormat, + dataFeatures || [], getMapExtentPadding(mapExtentPadding), ); } else { @@ -311,11 +344,9 @@ export const OlChartMap = (props: OlChartMapProps) => { geomFormat === GeometryFormat.WKB || geomFormat === GeometryFormat.WKT ) { - fitMapToDataRecords( + fitMapToFeatures( olMap, - data, - geomColumn, - geomFormat, + dataFeatures || [], getMapExtentPadding(mapExtentPadding), ); } else { @@ -333,6 +364,7 @@ export const OlChartMap = (props: OlChartMapProps) => { geomFormat, geomColumn, processedData, + dataFeatures, mapExtentPadding, ]); @@ -369,6 +401,47 @@ export const OlChartMap = (props: OlChartMapProps) => { return filteredRecords; }, [processedData, timeColumn, timeFilter, geomFormat]); + const filteredFeatures = useMemo(() => { + if ( + geomFormat === GeometryFormat.WKB || + geomFormat === GeometryFormat.WKT + ) { + if (filteredData === processedData) { + return dataFeatures; + } + return dataRecordsToOlFeatures( + filteredData as DataRecord[], + geomColumn, + geomFormat, + ) as OlFeature[]; + } + return undefined; + }, [filteredData, processedData, dataFeatures, geomColumn, geomFormat]); + + const visibleFeatures = useMemo(() => { + if ( + geomFormat === GeometryFormat.WKB || + geomFormat === GeometryFormat.WKT + ) { + return (filteredFeatures || []) as OlFeature[]; + } + return new GeoJSON().readFeatures(filteredData, { + featureProjection: 'EPSG:3857', + }) as OlFeature[]; + }, [filteredData, filteredFeatures, geomFormat]); + + const mergedVisibleFeatures = useMemo(() => { + if (!mergePolygonEntities || !areFeaturesPolygonal(visibleFeatures)) { + return undefined; + } + return mergePolygonFeatures(visibleFeatures) as OlFeature[]; + }, [mergePolygonEntities, visibleFeatures]); + + const usesMergedPresentation = + mergePolygonEntities && + areFeaturesPolygonal(visibleFeatures) && + Boolean(mergedVisibleFeatures?.length); + /** * Update layers */ @@ -406,25 +479,82 @@ export const OlChartMap = (props: OlChartMapProps) => { useEffect(() => { currentDataLayers?.forEach(dataLayer => { const source = dataLayer.getSource(); - let features: OlFeature[]; - if ( - geomFormat === GeometryFormat.WKB || - geomFormat === GeometryFormat.WKT - ) { - features = dataRecordsToOlFeatures( - filteredData as DataRecord[], - geomColumn, - geomFormat, - ) as OlFeature[]; - } else { - features = new GeoJSON().readFeatures(filteredData, { - featureProjection: 'EPSG:3857', - }); - } source?.clear(); - source?.addFeatures(features); + source?.addFeatures(visibleFeatures); }); - }, [currentDataLayers, filteredData, geomColumn, geomFormat]); + }, [currentDataLayers, visibleFeatures]); + + useEffect(() => { + removeManagedLayer(olMap, PRESENTATION_LAYER_NAME); + + if ( + !currentDataLayers || + currentDataLayers.length === 0 || + !usesMergedPresentation || + !mergedVisibleFeatures + ) { + return undefined; + } + + const presentationLayer = createPresentationLayer( + mergedVisibleFeatures, + mergedPolygonStrokeColor, + mergedPolygonStrokeWidth, + ); + olMap.addLayer(presentationLayer); + + return () => { + removeManagedLayer(olMap, PRESENTATION_LAYER_NAME); + }; + }, [ + currentDataLayers, + mergedPolygonStrokeColor, + mergedPolygonStrokeWidth, + mergedVisibleFeatures, + olMap, + usesMergedPresentation, + ]); + + useEffect(() => { + removeManagedLayer(olMap, MASK_LAYER_NAME); + + if (!showAreaMask || !areFeaturesPolygonal(visibleFeatures)) { + return undefined; + } + + const focusFeatures = + usesMergedPresentation && mergedVisibleFeatures + ? mergedVisibleFeatures + : visibleFeatures; + const projectionExtent = olMap.getView().getProjection().getExtent(); + if (!projectionExtent) { + return undefined; + } + + const maskFeature = createMaskFeature(focusFeatures, projectionExtent); + if (!maskFeature) { + return undefined; + } + + const maskLayer = createMaskLayer( + maskFeature, + areaMaskColor, + areaMaskOpacity / 100, + ); + olMap.addLayer(maskLayer); + + return () => { + removeManagedLayer(olMap, MASK_LAYER_NAME); + }; + }, [ + mergedVisibleFeatures, + areaMaskOpacity, + areaMaskColor, + olMap, + showAreaMask, + usesMergedPresentation, + visibleFeatures, + ]); useEffect(() => { removeSelectionLayer(olMap); @@ -438,21 +568,30 @@ export const OlChartMap = (props: OlChartMapProps) => { filterState, crossFilterColumn, ); + const baseOpacity = usesMergedPresentation ? 0.01 : FULL_OPACITY; if (filterState.value !== null && filterState.value !== undefined) { - const selectionLayer = createSelectionLayer( - currentDataLayers, - selectedFeatures, - ); - olMap.addLayer(selectionLayer); + if (!usesMergedPresentation) { + const selectionLayer = createSelectionLayer( + currentDataLayers, + selectedFeatures, + ); + olMap.addLayer(selectionLayer); + } setSelectionBackgroundOpacity( currentDataLayers, - SELECTION_BACKGROUND_OPACITY, + usesMergedPresentation ? 0.01 : SELECTION_BACKGROUND_OPACITY, ); } else { - setSelectionBackgroundOpacity(currentDataLayers, FULL_OPACITY); + setSelectionBackgroundOpacity(currentDataLayers, baseOpacity); } - }, [filterState, currentDataLayers, olMap, filteredData, crossFilterColumn]); + }, [ + crossFilterColumn, + currentDataLayers, + filterState, + olMap, + usesMergedPresentation, + ]); useEffect(() => { const { extentMode, fixedMaxX, fixedMaxY, fixedMinX, fixedMinY } = diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/controlPanel.tsx index 533b2f7c69f4..2018cc2963af 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/controlPanel.tsx @@ -39,6 +39,10 @@ import { dataToFeatureCollection, } from '../util/dataUtil'; import { MapViewConfigs } from '../types'; +import { + DEFAULT_MASK_COLOR, + DEFAULT_MERGED_STROKE_COLOR, +} from '../constants'; const columnsControl: typeof sharedControls.groupby = { ...sharedControls.groupby, @@ -319,6 +323,100 @@ const config: ControlPanelConfig = { }, }, ], + [ + { + name: 'show_area_mask', + config: { + type: 'CheckboxControl', + label: t('Show Area Mask'), + renderTrigger: true, + default: false, + description: t( + 'Fade the area outside the currently displayed polygon perimeter.', + ), + }, + }, + ], + [ + { + name: 'area_mask_opacity', + config: { + type: 'SliderControl', + label: t('Area Mask Opacity'), + renderTrigger: true, + default: 75, + min: 0, + max: 100, + step: 5, + description: t('Opacity of the area mask overlay.'), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.show_area_mask?.value), + }, + }, + ], + [ + { + name: 'area_mask_color', + config: { + type: 'ColorPickerControl', + label: t('Area Mask Color'), + renderTrigger: true, + default: DEFAULT_MASK_COLOR, + description: t('Fill color used for the area mask overlay.'), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.show_area_mask?.value), + }, + }, + ], + [ + { + name: 'merge_polygon_entities', + config: { + type: 'CheckboxControl', + label: t('Merge Polygon Entities'), + renderTrigger: true, + default: false, + description: t( + 'Display filtered polygon entities as merged perimeters instead of keeping their internal boundaries.', + ), + }, + }, + ], + [ + { + name: 'merged_polygon_stroke_width', + config: { + type: 'SliderControl', + label: t('Merged Polygon Stroke Width'), + renderTrigger: true, + default: 2, + min: 1, + max: 10, + step: 1, + description: t( + 'Stroke width used for merged polygon perimeters.', + ), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.merge_polygon_entities?.value), + }, + }, + ], + [ + { + name: 'merged_polygon_stroke_color', + config: { + type: 'ColorPickerControl', + label: t('Merged Polygon Stroke Color'), + renderTrigger: true, + default: DEFAULT_MERGED_STROKE_COLOR, + description: t( + 'Stroke color used for merged polygon perimeters.', + ), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.merge_polygon_entities?.value), + }, + }, + ], [ { name: 'tooltip_template', diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/transformProps.ts b/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/transformProps.ts index b487029e8bdf..a4d92e48132b 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/transformProps.ts @@ -17,6 +17,10 @@ * under the License. */ import { ChartProps } from '@superset-ui/core'; +import { + DEFAULT_MASK_COLOR, + DEFAULT_MERGED_STROKE_COLOR, +} from '../constants'; export default function transformProps(chartProps: ChartProps) { /** @@ -74,6 +78,12 @@ export default function transformProps(chartProps: ChartProps) { showTimeslider, showLegend, showTooltip, + showAreaMask, + areaMaskOpacity, + areaMaskColor = DEFAULT_MASK_COLOR, + mergePolygonEntities, + mergedPolygonStrokeWidth = 2, + mergedPolygonStrokeColor = DEFAULT_MERGED_STROKE_COLOR, timeColumn, tooltipTemplate, } = formData; @@ -108,6 +118,12 @@ export default function transformProps(chartProps: ChartProps) { showTimeslider, showLegend, showTooltip, + showAreaMask, + areaMaskOpacity, + areaMaskColor, + mergePolygonEntities, + mergedPolygonStrokeWidth, + mergedPolygonStrokeColor, theme, timeColumn, tooltipTemplate, diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/types.ts b/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/types.ts index 7ff80cce5065..e9765489aa6a 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/types.ts +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/src/thematic/types.ts @@ -42,6 +42,13 @@ export interface ThematicMapPluginStylesProps { theme: SupersetTheme; } +type RgbaColor = { + r: number; + g: number; + b: number; + a: number; +}; + interface ThematicMapPluginCustomizeProps { emitCrossFilters: boolean; filterState: FilterState; @@ -63,6 +70,12 @@ interface ThematicMapPluginCustomizeProps { tooltipTemplate: string; showLegend: boolean; showTooltip: boolean; + showAreaMask: boolean; + areaMaskOpacity: number; + areaMaskColor: RgbaColor; + mergePolygonEntities: boolean; + mergedPolygonStrokeWidth: number; + mergedPolygonStrokeColor: RgbaColor; } export type ThematicMapPluginProps = ThematicMapPluginStylesProps & diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/geometryUtil.ts b/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/geometryUtil.ts index 7ae245192c40..549dcac1f390 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/geometryUtil.ts +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/geometryUtil.ts @@ -23,10 +23,22 @@ import GeoJSON from 'ol/format/GeoJSON'; import Feature from 'ol/Feature'; -import { Point as OlPoint } from 'ol/geom'; +import { + MultiPolygon as OlMultiPolygon, + Point as OlPoint, + Polygon as OlPolygon, +} from 'ol/geom'; import VectorSource from 'ol/source/Vector'; import { Point as GeoJsonPoint } from 'geojson'; import { FitOptions } from 'ol/View'; +import { Extent } from 'ol/extent'; + +// Precision used to normalize coordinates before comparing polygon edges. +// Higher values make edge matching stricter and therefore reduce merging. +// Lower values make edge matching more permissive and can merge nearly +// coincident borders. In the future this could become a configurable UI +// setting if users need to tune the merge behavior for their datasets. +const EDGE_COMPARISON_PRECISION = 6; /** * Extracts the coordinate from a Point GeoJSON in the current map projection. @@ -69,3 +81,225 @@ export const getMapExtentPadding = ( mapExtentPadding?: number, ): FitOptions['padding'] | undefined => mapExtentPadding !== undefined ? Array(4).fill(mapExtentPadding) : undefined; + +const cloneRing = (ring: number[][]) => + ring.map(coord => [...coord]) as number[][]; + +const closeRing = (ring: number[][]) => { + if (ring.length === 0) { + return ring; + } + const [firstX, firstY] = ring[0]; + const [lastX, lastY] = ring[ring.length - 1]; + if (firstX === lastX && firstY === lastY) { + return ring; + } + return [...ring, [...ring[0]]]; +}; + +// Coordinates are rounded before edge comparison so tiny floating-point +// differences do not prevent exact shared-border detection. This is not a +// spatial tolerance or buffer: edges are still only merged when their +// normalized coordinates match after rounding. +const getCoordinateKey = (coord: number[]) => + coord + .map(value => Number(value.toFixed(EDGE_COMPARISON_PRECISION))) + .join(','); + +const getRingSignedArea = (ring: number[][]) => + ring.slice(0, -1).reduce((area, coord, idx) => { + const next = ring[idx + 1]; + return area + coord[0] * next[1] - next[0] * coord[1]; + }, 0); + +const getPolygonOuterRings = (features: Feature[]) => + features.flatMap(feature => { + const geometry = feature.getGeometry(); + if (geometry instanceof OlPolygon) { + return [closeRing(cloneRing(geometry.getCoordinates()[0]))]; + } + if (geometry instanceof OlMultiPolygon) { + return geometry + .getCoordinates() + .map(polygonCoords => closeRing(cloneRing(polygonCoords[0]))); + } + return []; + }); + +/** + * Check whether all provided features use polygonal geometries. + * + * Supports both Polygon and MultiPolygon OpenLayers geometries. + * + * @param features The features to inspect + * @returns True if all features are polygonal, false otherwise + */ +export const areFeaturesPolygonal = (features: Feature[]) => + features.length > 0 && + features.every(feature => { + const geometry = feature.getGeometry(); + return geometry instanceof OlPolygon || geometry instanceof OlMultiPolygon; + }); + +/** + * Merge polygon features by removing shared internal edges. + * + * This is intended for display-oriented aggregation of adjacent polygons, for + * example when only the outer perimeter of a filtered administrative area + * should remain visible. + * + * We could use a geometry processing library like JSTS or turf for this, + * but this custom avoid heavy union operations and avoid to add a dependency to the project. + * + * @param features The polygon features to merge + * @returns The merged polygon features, or the original features if merging is + * not applicable + */ +export const mergePolygonFeatures = (features: Feature[]) => { + if (!areFeaturesPolygonal(features)) { + return features; + } + + // Count normalized polygon edges so shared borders can be removed and only + // the outer perimeter remains visible in the merged presentation. + const edgeCounts = new Map< + string, + { + count: number; + start: number[]; + end: number[]; + startKey: string; + endKey: string; + } + >(); + + getPolygonOuterRings(features).forEach(ring => { + for (let i = 0; i < ring.length - 1; i += 1) { + const start = ring[i]; + const end = ring[i + 1]; + const startKey = getCoordinateKey(start); + const endKey = getCoordinateKey(end); + const edgeKey = + startKey < endKey ? `${startKey}|${endKey}` : `${endKey}|${startKey}`; + const previous = edgeCounts.get(edgeKey); + edgeCounts.set(edgeKey, { + count: (previous?.count || 0) + 1, + start, + end, + startKey, + endKey, + }); + } + }); + + const boundaryEdges = Array.from(edgeCounts.values()) + .filter(edge => edge.count % 2 === 1) + .map((edge, idx) => ({ ...edge, id: `edge-${idx}` })); + + // Build an adjacency graph from the remaining outer edges in order to stitch + // them back into closed rings. + const adjacency = new Map(); + boundaryEdges.forEach(edge => { + adjacency.set(edge.startKey, [ + ...(adjacency.get(edge.startKey) || []), + edge, + ]); + adjacency.set(edge.endKey, [...(adjacency.get(edge.endKey) || []), edge]); + }); + + const usedEdges = new Set(); + const mergedFeatures: Feature[] = []; + + boundaryEdges.forEach(edge => { + if (usedEdges.has(edge.id)) { + return; + } + + const ring = [edge.start, edge.end].map(coord => [...coord]); + let currentKey = edge.endKey; + let previousKey = edge.startKey; + usedEdges.add(edge.id); + + // Walk from edge to edge until the boundary closes back on the starting + // coordinate, producing one merged outer ring. + while (currentKey !== edge.startKey) { + const candidates = adjacency.get(currentKey) || []; + let nextEdge; + for (let i = 0; i < candidates.length; i += 1) { + const candidate = candidates[i]; + if ( + !usedEdges.has(candidate.id) && + (candidate.startKey !== previousKey || + candidate.endKey !== currentKey) && + (candidate.endKey !== previousKey || + candidate.startKey !== currentKey) + ) { + nextEdge = candidate; + break; + } + } + + if (!nextEdge) { + break; + } + + usedEdges.add(nextEdge.id); + + const nextKey = + nextEdge.startKey === currentKey ? nextEdge.endKey : nextEdge.startKey; + const nextCoord = + nextEdge.startKey === currentKey ? nextEdge.end : nextEdge.start; + + ring.push([...nextCoord]); + previousKey = currentKey; + currentKey = nextKey; + } + + const closedRing = closeRing(ring); + if (closedRing.length >= 4) { + mergedFeatures.push(new Feature(new OlPolygon([closedRing]))); + } + }); + + return mergedFeatures.length > 0 ? mergedFeatures : features; +}; + +/** + * Create a polygon mask feature that covers the whole extent except the focused + * polygon area. + * + * The resulting feature uses the provided extent as the outer ring and the + * focused polygon perimeters as holes. + * + * @param focusFeatures The polygon features defining the visible focus area + * @param extent The outer extent covered by the mask + * @returns The mask feature or undefined if the input features are not + * polygonal + */ +export const createMaskFeature = (focusFeatures: Feature[], extent: Extent) => { + if (!areFeaturesPolygonal(focusFeatures)) { + return undefined; + } + + // The mask is a full-extent polygon with the focused polygons inserted as + // holes, so only the outside area gets faded by the overlay style. + const [minX, minY, maxX, maxY] = extent; + const outerRing = [ + [minX, minY], + [minX, maxY], + [maxX, maxY], + [maxX, minY], + [minX, minY], + ]; + const outerArea = getRingSignedArea(outerRing); + + const holes = getPolygonOuterRings(focusFeatures).map(ring => { + const holeArea = getRingSignedArea(ring); + if (Math.sign(outerArea) === Math.sign(holeArea)) { + return [...ring].reverse(); + } + return ring; + }); + + return new Feature(new OlPolygon([outerRing, ...holes])); +}; diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/layerUtil.tsx b/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/layerUtil.tsx index 7b1be07a9f0f..a1576b467d74 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/layerUtil.tsx +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/layerUtil.tsx @@ -32,6 +32,7 @@ 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 { Fill, Stroke, Style } from 'ol/style'; import { WmsLayerConf, WfsLayerConf, @@ -46,7 +47,12 @@ import { isXyzLayerConf, } from '../typeguards'; import { isVersionBelow } from './serviceUtil'; -import { LAYER_NAME_PROP, SELECTION_LAYER_NAME } from '../constants'; +import { + LAYER_NAME_PROP, + MASK_LAYER_NAME, + PRESENTATION_LAYER_NAME, + SELECTION_LAYER_NAME, +} from '../constants'; /** * Create a WMS layer. @@ -206,17 +212,32 @@ export const createLayer = async (layerConf: LayerConf) => { return layer; }; -export const removeSelectionLayer = (olMap: Map) => { - const selectionLayer = olMap +/** + * Remove a managed helper layer from the map by its internal layer name. + * + * @param olMap The OpenLayers map + * @param layerName The internal layer name to remove + */ +export const removeManagedLayer = (olMap: Map, layerName: string) => { + const layer = olMap .getLayers() .getArray() - .filter(l => l.get(LAYER_NAME_PROP) === SELECTION_LAYER_NAME) + .filter(l => l.get(LAYER_NAME_PROP) === layerName) .pop(); - if (selectionLayer) { - olMap.removeLayer(selectionLayer); + if (layer) { + olMap.removeLayer(layer); } }; +/** + * Remove the dedicated selection layer from the map. + * + * @param olMap The OpenLayers map + */ +export const removeSelectionLayer = (olMap: Map) => { + removeManagedLayer(olMap, SELECTION_LAYER_NAME); +}; + export const getSelectedFeatures = ( dataLayers: VectorLayer[], filterState: FilterState, @@ -249,24 +270,97 @@ export const setSelectionBackgroundOpacity = ( }); }; +/** + * Create a layer used to highlight the currently selected features. + * + * The layer reuses the style of the first data layer so the highlighted + * features keep the same visual appearance as the original data. + * + * @param dataLayers The current data layers + * @param features The selected features to display + * @returns The created selection layer + */ export const createSelectionLayer = ( dataLayers: VectorLayer[], features: Feature[], ) => { + const layerStyle = dataLayers[0]?.getStyle(); const selectionLayer = new VectorLayer({ source: new VectorSource({ features, }), + style: layerStyle, }); selectionLayer.set(LAYER_NAME_PROP, SELECTION_LAYER_NAME); - // TODO how can we handle multiple data layers? - const layerStyle = dataLayers[0].getStyle(); - if (layerStyle) { - selectionLayer.setStyle(layerStyle); - } return selectionLayer; }; +/** + * Create a presentation layer for derived polygon rendering. + * + * This is used for display-only geometries, for example merged polygon + * perimeters, while interactions continue to rely on the original data layer. + * + * @param features The features to render in the presentation layer + * @param strokeColor The stroke color used for the merged perimeter + * @param strokeWidth The stroke width used for the merged perimeter + * @returns The created presentation layer + */ +export const createPresentationLayer = ( + features: Feature[], + strokeColor?: { r: number; g: number; b: number; a: number }, + strokeWidth = 2, +) => { + const safeStrokeColor = strokeColor || { r: 0, g: 0, b: 0, a: 1 }; + const presentationLayer = new VectorLayer({ + source: new VectorSource({ + features, + }), + style: new Style({ + // Keep only the merged perimeter visible on the presentation layer. + // eslint-disable-next-line theme-colors/no-literal-colors + fill: new Fill({ color: 'rgba(255, 255, 255, 0)' }), + // eslint-disable-next-line theme-colors/no-literal-colors + stroke: new Stroke({ + color: `rgba(${safeStrokeColor.r}, ${safeStrokeColor.g}, ${safeStrokeColor.b}, ${safeStrokeColor.a})`, + width: strokeWidth, + }), + }), + }); + presentationLayer.set(LAYER_NAME_PROP, PRESENTATION_LAYER_NAME); + return presentationLayer; +}; + +/** + * Create a layer that visually masks the area outside a focused perimeter. + * + * @param maskFeature The polygon feature describing the mask geometry + * @param color The fill color of the mask + * @param opacity The fill opacity of the mask, in the range 0..1 + * @returns The created mask layer + */ +export const createMaskLayer = ( + maskFeature: Feature, + color?: { r: number; g: number; b: number; a: number }, + opacity = 0.65, +) => { + const safeColor = color || { r: 255, g: 255, b: 255, a: 1 }; + const maskLayer = new VectorLayer({ + source: new VectorSource({ + features: [maskFeature], + }), + style: new Style({ + // Keep the active area visible and softly fade the outside. + // eslint-disable-next-line theme-colors/no-literal-colors + fill: new Fill({ + color: `rgba(${safeColor.r}, ${safeColor.g}, ${safeColor.b}, ${opacity})`, + }), + }), + }); + maskLayer.set(LAYER_NAME_PROP, MASK_LAYER_NAME); + return maskLayer; +}; + export const getDefaultStyle = () => ({ name: t('Default Style'), rules: [ diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/mapUtil.tsx b/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/mapUtil.tsx index 90c0385fbe03..cdaf6af46299 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/mapUtil.tsx +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/mapUtil.tsx @@ -72,6 +72,14 @@ const fitToData = ( } }; +export const fitMapToFeatures = ( + olMap: Map, + features: Feature[], + padding?: FitOptions['padding'] | undefined, +) => { + fitToData(olMap, features, padding); +}; + const extractSridFromWkt = (wkt: string) => { const extract: { geom: string; srid: string | null } = { geom: wkt, @@ -127,35 +135,36 @@ export const dataRecordsToOlFeatures = ( geomColumn: string, geomFormat: GeometryFormat.WKB | GeometryFormat.WKT, ) => { - let format: WKB | WKT = new WKB(); + const format: WKB | WKT = + geomFormat === GeometryFormat.WKT ? new WKT() : new WKB(); + const features: Feature[] = []; + const defaultOpts: any = { featureProjection: 'EPSG:3857' }; + const defaultWktOpts: any = { + featureProjection: 'EPSG:3857', + dataProjection: 'EPSG:4326', + }; + + for (let i = 0; i < dataRecords.length; i += 1) { + const item = dataRecords[i]; + const geom = item[geomColumn]; + if (typeof geom !== 'string') continue; + + let cleanedGeom = geom; + let opts = defaultOpts; + if (geomFormat === GeometryFormat.WKT) { + const extract = extractSridFromWkt(geom); + cleanedGeom = extract.geom; + opts = extract.srid + ? { featureProjection: 'EPSG:3857', dataProjection: extract.srid } + : defaultWktOpts; + } + + const feature = format.readFeature(cleanedGeom, opts); + + feature.setProperties(item); - if (geomFormat === GeometryFormat.WKT) { - format = new WKT(); + features.push(feature); } - const features = dataRecords - .map(item => { - const geom = item[geomColumn]; - if (typeof geom !== 'string') { - return undefined; - } - - let cleanedGeom = geom; - const opts: any = { - featureProjection: 'EPSG:3857', - }; - if (geomFormat === GeometryFormat.WKT) { - const extract = extractSridFromWkt(geom); - cleanedGeom = extract.geom; - if (extract.srid) { - opts.dataProjection = extract.srid; - } - } - const feature = format.readFeature(cleanedGeom, opts); - feature.setProperties({ ...item }); - - return feature; - }) - .filter(f => f !== undefined); return features; };