mirror of
https://github.com/grafana/grafana.git
synced 2025-02-16 18:34:52 -06:00
* Geomap: Add dynamic initial view options * Add control options for dynamic initial view * Add handling for last only scope * Remove stale todos * Only reinitialize map view during data if fit * Add options for data fit In map init, remove dependency on layers input. Add a boolean to map view config to handle all layers, with default set to true. Also, add layer string to handle currently selected layer. Change some verbage. In map view editor, add controls for data extent options. Only display layer selection when all layers is not selected. Update layer extent function to handle all options permutations. When all layers is selected, return all data for extent calculation. When last only is selected, only return last data point in matching layer for extent calculation. Otherwise, return all data points in matching layer for extent calculation. * Change padding tooltip and hide for last only * Simplify getLayersExtent call * Add enums for data scope options * Update GeomapPanel for refactor merge * Move view init functions back into geomap class Re-apply data change handling and extent calculation handling. * Update padding tooltip verbage * Ensure geomap panel options layers are initialized * Clean up GeomapPanel file (betterer) * refactors / clean up * more cleanup Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
import { css } from '@emotion/css';
|
|
import { Global } from '@emotion/react';
|
|
import { Map as OpenLayersMap, MapBrowserEvent, View } from 'ol';
|
|
import Attribution from 'ol/control/Attribution';
|
|
import ScaleLine from 'ol/control/ScaleLine';
|
|
import Zoom from 'ol/control/Zoom';
|
|
import { Coordinate } from 'ol/coordinate';
|
|
import { isEmpty } from 'ol/extent';
|
|
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
|
|
import { fromLonLat } from 'ol/proj';
|
|
import React, { Component, ReactNode } from 'react';
|
|
import { Subscription } from 'rxjs';
|
|
|
|
import { DataHoverEvent, GrafanaTheme, PanelData, PanelProps } from '@grafana/data';
|
|
import { config } from '@grafana/runtime';
|
|
import { PanelContext, PanelContextRoot, stylesFactory } from '@grafana/ui';
|
|
import { PanelEditExitedEvent } from 'app/types/events';
|
|
|
|
import { GeomapOverlay, OverlayProps } from './GeomapOverlay';
|
|
import { GeomapTooltip } from './GeomapTooltip';
|
|
import { DebugOverlay } from './components/DebugOverlay';
|
|
import { MeasureOverlay } from './components/MeasureOverlay';
|
|
import { MeasureVectorLayer } from './components/MeasureVectorLayer';
|
|
import { GeomapHoverPayload } from './event';
|
|
import { getGlobalStyles } from './globalStyles';
|
|
import { defaultMarkersConfig } from './layers/data/markersLayer';
|
|
import { DEFAULT_BASEMAP_CONFIG } from './layers/registry';
|
|
import { ControlsOptions, GeomapPanelOptions, MapLayerState, MapViewConfig, TooltipMode } from './types';
|
|
import { getActions } from './utils/actions';
|
|
import { getLayersExtent } from './utils/getLayersExtent';
|
|
import { applyLayerFilter, initLayer } from './utils/layers';
|
|
import { pointerClickListener, pointerMoveListener, setTooltipListeners } from './utils/tootltip';
|
|
import { updateMap, getNewOpenLayersMap, notifyPanelEditor } from './utils/utils';
|
|
import { centerPointRegistry, MapCenterID } from './view';
|
|
|
|
// Allows multiple panels to share the same view instance
|
|
let sharedView: View | undefined = undefined;
|
|
|
|
type Props = PanelProps<GeomapPanelOptions>;
|
|
interface State extends OverlayProps {
|
|
ttip?: GeomapHoverPayload;
|
|
ttipOpen: boolean;
|
|
legends: ReactNode[];
|
|
measureMenuActive?: boolean;
|
|
}
|
|
|
|
export class GeomapPanel extends Component<Props, State> {
|
|
static contextType = PanelContextRoot;
|
|
panelContext: PanelContext | undefined = undefined;
|
|
private subs = new Subscription();
|
|
|
|
globalCSS = getGlobalStyles(config.theme2);
|
|
|
|
mouseWheelZoom?: MouseWheelZoom;
|
|
style = getStyles(config.theme);
|
|
hoverPayload: GeomapHoverPayload = { point: {}, pageX: -1, pageY: -1 };
|
|
readonly hoverEvent = new DataHoverEvent(this.hoverPayload);
|
|
|
|
map?: OpenLayersMap;
|
|
mapDiv?: HTMLDivElement;
|
|
layers: MapLayerState[] = [];
|
|
readonly byName = new Map<string, MapLayerState>();
|
|
|
|
constructor(props: Props) {
|
|
super(props);
|
|
this.state = { ttipOpen: false, legends: [] };
|
|
this.subs.add(
|
|
this.props.eventBus.subscribe(PanelEditExitedEvent, (evt) => {
|
|
if (this.mapDiv && this.props.id === evt.payload) {
|
|
this.initMapRef(this.mapDiv);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.panelContext = this.context;
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.subs.unsubscribe();
|
|
for (const lyr of this.layers) {
|
|
lyr.handler.dispose?.();
|
|
}
|
|
}
|
|
|
|
shouldComponentUpdate(nextProps: Props) {
|
|
if (!this.map) {
|
|
return true; // not yet initialized
|
|
}
|
|
|
|
// Check for resize
|
|
if (this.props.height !== nextProps.height || this.props.width !== nextProps.width) {
|
|
this.map.updateSize();
|
|
}
|
|
|
|
// External data changed
|
|
if (this.props.data !== nextProps.data) {
|
|
this.dataChanged(nextProps.data);
|
|
}
|
|
|
|
// Options changed
|
|
if (this.props.options !== nextProps.options) {
|
|
this.optionsChanged(nextProps.options);
|
|
}
|
|
|
|
return true; // always?
|
|
}
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
|
if (this.map && (this.props.height !== prevProps.height || this.props.width !== prevProps.width)) {
|
|
this.map.updateSize();
|
|
}
|
|
// Check for a difference between previous data and component data
|
|
if (this.map && this.props.data !== prevProps.data) {
|
|
this.dataChanged(this.props.data);
|
|
}
|
|
}
|
|
|
|
/** This function will actually update the JSON model */
|
|
doOptionsUpdate(selected: number) {
|
|
const { options, onOptionsChange } = this.props;
|
|
const layers = this.layers;
|
|
this.map?.getLayers().forEach((l) => {
|
|
if (l instanceof MeasureVectorLayer) {
|
|
this.map?.removeLayer(l);
|
|
this.map?.addLayer(l);
|
|
}
|
|
});
|
|
onOptionsChange({
|
|
...options,
|
|
basemap: layers[0].options,
|
|
layers: layers.slice(1).map((v) => v.options),
|
|
});
|
|
|
|
notifyPanelEditor(this, layers, selected);
|
|
this.setState({ legends: this.getLegends() });
|
|
}
|
|
|
|
actions = getActions(this);
|
|
|
|
/**
|
|
* Called when the panel options change
|
|
*
|
|
* NOTE: changes to basemap and layers are handled independently
|
|
*/
|
|
optionsChanged(options: GeomapPanelOptions) {
|
|
const oldOptions = this.props.options;
|
|
if (options.view !== oldOptions.view) {
|
|
const [updatedSharedView, view] = this.initMapView(options.view, sharedView);
|
|
sharedView = updatedSharedView;
|
|
|
|
if (this.map && view) {
|
|
this.map.setView(view);
|
|
}
|
|
}
|
|
|
|
if (options.controls !== oldOptions.controls) {
|
|
this.initControls(options.controls ?? { showZoom: true, showAttribution: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when PanelData changes (query results etc)
|
|
*/
|
|
dataChanged(data: PanelData) {
|
|
// Only update if panel data matches component data
|
|
if (data === this.props.data) {
|
|
for (const state of this.layers) {
|
|
applyLayerFilter(state.handler, state.options, this.props.data);
|
|
}
|
|
}
|
|
|
|
// Because data changed, check map view and change if needed (data fit)
|
|
const v = centerPointRegistry.getIfExists(this.props.options.view.id);
|
|
if (v && v.id === MapCenterID.Fit) {
|
|
const [, view] = this.initMapView(this.props.options.view);
|
|
|
|
if (this.map && view) {
|
|
this.map.setView(view);
|
|
}
|
|
}
|
|
}
|
|
|
|
initMapRef = async (div: HTMLDivElement) => {
|
|
this.mapDiv = div;
|
|
if (this.map) {
|
|
this.map.dispose();
|
|
}
|
|
|
|
if (!div) {
|
|
this.map = undefined;
|
|
return;
|
|
}
|
|
const { options } = this.props;
|
|
|
|
const map = getNewOpenLayersMap(this, options, div);
|
|
|
|
this.byName.clear();
|
|
const layers: MapLayerState[] = [];
|
|
try {
|
|
layers.push(await initLayer(this, map, options.basemap ?? DEFAULT_BASEMAP_CONFIG, true));
|
|
|
|
// Default layer values
|
|
if (!options.layers) {
|
|
options.layers = [defaultMarkersConfig];
|
|
}
|
|
|
|
for (const lyr of options.layers) {
|
|
layers.push(await initLayer(this, map, lyr, false));
|
|
}
|
|
} catch (ex) {
|
|
console.error('error loading layers', ex);
|
|
}
|
|
|
|
for (const lyr of layers) {
|
|
map.addLayer(lyr.layer);
|
|
}
|
|
this.layers = layers;
|
|
this.map = map; // redundant
|
|
this.initViewExtent(map.getView(), options.view);
|
|
|
|
this.mouseWheelZoom = new MouseWheelZoom();
|
|
this.map?.addInteraction(this.mouseWheelZoom);
|
|
|
|
updateMap(this, options);
|
|
setTooltipListeners(this);
|
|
notifyPanelEditor(this, layers, layers.length - 1);
|
|
|
|
this.setState({ legends: this.getLegends() });
|
|
};
|
|
|
|
clearTooltip = () => {
|
|
if (this.state.ttip && !this.state.ttipOpen) {
|
|
this.tooltipPopupClosed();
|
|
}
|
|
};
|
|
|
|
tooltipPopupClosed = () => {
|
|
this.setState({ ttipOpen: false, ttip: undefined });
|
|
};
|
|
|
|
pointerClickListener = (evt: MapBrowserEvent<UIEvent>) => {
|
|
pointerClickListener(evt, this);
|
|
};
|
|
|
|
pointerMoveListener = (evt: MapBrowserEvent<UIEvent>) => {
|
|
pointerMoveListener(evt, this);
|
|
};
|
|
|
|
initMapView = (config: MapViewConfig, sharedView?: View | undefined): Array<View | undefined> => {
|
|
let view = new View({
|
|
center: [0, 0],
|
|
zoom: 1,
|
|
showFullExtent: true, // allows zooming so the full range is visible
|
|
});
|
|
|
|
// With shared views, all panels use the same view instance
|
|
if (config.shared) {
|
|
if (!sharedView) {
|
|
sharedView = view;
|
|
} else {
|
|
view = sharedView;
|
|
}
|
|
}
|
|
this.initViewExtent(view, config);
|
|
|
|
return [sharedView, view];
|
|
};
|
|
|
|
initViewExtent(view: View, config: MapViewConfig) {
|
|
const v = centerPointRegistry.getIfExists(config.id);
|
|
if (v) {
|
|
let coord: Coordinate | undefined = undefined;
|
|
if (v.lat == null) {
|
|
if (v.id === MapCenterID.Coordinates) {
|
|
coord = [config.lon ?? 0, config.lat ?? 0];
|
|
} else if (v.id === MapCenterID.Fit) {
|
|
const extent = getLayersExtent(this.layers, config.allLayers, config.lastOnly, config.layer);
|
|
if (!isEmpty(extent)) {
|
|
const padding = config.padding ?? 5;
|
|
const res = view.getResolutionForExtent(extent, this.map?.getSize());
|
|
const maxZoom = config.zoom ?? config.maxZoom;
|
|
view.fit(extent, {
|
|
maxZoom: maxZoom,
|
|
});
|
|
view.setResolution(res * (padding / 100 + 1));
|
|
const adjustedZoom = view.getZoom();
|
|
if (adjustedZoom && maxZoom && adjustedZoom > maxZoom) {
|
|
view.setZoom(maxZoom);
|
|
}
|
|
}
|
|
} else {
|
|
// TODO: view requires special handling
|
|
}
|
|
} else {
|
|
coord = [v.lon ?? 0, v.lat ?? 0];
|
|
}
|
|
if (coord) {
|
|
view.setCenter(fromLonLat(coord));
|
|
}
|
|
}
|
|
|
|
if (config.maxZoom) {
|
|
view.setMaxZoom(config.maxZoom);
|
|
}
|
|
if (config.minZoom) {
|
|
view.setMaxZoom(config.minZoom);
|
|
}
|
|
if (config.zoom && v?.id !== MapCenterID.Fit) {
|
|
view.setZoom(config.zoom);
|
|
}
|
|
}
|
|
|
|
initControls(options: ControlsOptions) {
|
|
if (!this.map) {
|
|
return;
|
|
}
|
|
this.map.getControls().clear();
|
|
|
|
if (options.showZoom) {
|
|
this.map.addControl(new Zoom());
|
|
}
|
|
|
|
if (options.showScale) {
|
|
this.map.addControl(
|
|
new ScaleLine({
|
|
units: options.scaleUnits,
|
|
minWidth: 100,
|
|
})
|
|
);
|
|
}
|
|
|
|
this.mouseWheelZoom!.setActive(Boolean(options.mouseWheelZoom));
|
|
|
|
if (options.showAttribution) {
|
|
this.map.addControl(new Attribution({ collapsed: true, collapsible: true }));
|
|
}
|
|
|
|
// Update the react overlays
|
|
let topRight1: ReactNode[] = [];
|
|
if (options.showMeasure) {
|
|
topRight1 = [
|
|
<MeasureOverlay
|
|
key="measure"
|
|
map={this.map}
|
|
// Lifts menuActive state and resets tooltip state upon close
|
|
menuActiveState={(value: boolean) => {
|
|
this.setState({ ttipOpen: value, measureMenuActive: value });
|
|
}}
|
|
/>,
|
|
];
|
|
}
|
|
|
|
let topRight2: ReactNode[] = [];
|
|
if (options.showDebug) {
|
|
topRight2 = [<DebugOverlay key="debug" map={this.map} />];
|
|
}
|
|
|
|
this.setState({ topRight1, topRight2 });
|
|
}
|
|
|
|
getLegends() {
|
|
const legends: ReactNode[] = [];
|
|
for (const state of this.layers) {
|
|
if (state.handler.legend) {
|
|
legends.push(<div key={state.options.name}>{state.handler.legend}</div>);
|
|
}
|
|
}
|
|
|
|
return legends;
|
|
}
|
|
|
|
render() {
|
|
let { ttip, ttipOpen, topRight1, legends, topRight2 } = this.state;
|
|
const { options } = this.props;
|
|
const showScale = options.controls.showScale;
|
|
if (!ttipOpen && options.tooltip?.mode === TooltipMode.None) {
|
|
ttip = undefined;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Global styles={this.globalCSS} />
|
|
<div className={this.style.wrap} onMouseLeave={this.clearTooltip}>
|
|
<div className={this.style.map} ref={this.initMapRef}></div>
|
|
<GeomapOverlay
|
|
bottomLeft={legends}
|
|
topRight1={topRight1}
|
|
topRight2={topRight2}
|
|
blStyle={{ bottom: showScale ? '35px' : '8px' }}
|
|
/>
|
|
</div>
|
|
<GeomapTooltip ttip={ttip} isOpen={ttipOpen} onClose={this.tooltipPopupClosed} />
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
|
|
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|
wrap: css`
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
`,
|
|
map: css`
|
|
position: absolute;
|
|
z-index: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
`,
|
|
}));
|