mirror of
https://github.com/apache/superset.git
synced 2026-07-18 20:55:47 +00:00
415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
/**
|
|
* 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 { useSelector } from 'react-redux';
|
|
|
|
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-es';
|
|
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 locale = useSelector((state: any) => state?.common?.locale);
|
|
|
|
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,
|
|
locale,
|
|
});
|
|
|
|
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,
|
|
locale,
|
|
]);
|
|
|
|
return (
|
|
<div
|
|
id={mapId}
|
|
style={{
|
|
height: `${height}px`,
|
|
width: `${width}px`,
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default OlChartMap;
|