The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/plugins/panel/geomap/GeomapPanel.tsx

386 lines
11 KiB

import React, { Component, ReactNode } from 'react';
import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry, defaultBaseLayer } from './layers/registry';
import { Map, MapBrowserEvent, View } from 'ol';
import Attribution from 'ol/control/Attribution';
import Zoom from 'ol/control/Zoom';
import ScaleLine from 'ol/control/ScaleLine';
import BaseLayer from 'ol/layer/Base';
import { defaults as interactionDefaults } from 'ol/interaction';
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
import {
PanelData,
MapLayerHandler,
MapLayerOptions,
PanelProps,
GrafanaTheme,
DataHoverClearEvent,
DataHoverEvent,
DataFrame,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { ControlsOptions, GeomapPanelOptions, MapViewConfig } from './types';
import { centerPointRegistry, MapCenterID } from './view';
import { fromLonLat, toLonLat } from 'ol/proj';
import { Coordinate } from 'ol/coordinate';
import { css } from '@emotion/css';
import { Portal, stylesFactory, VizTooltipContainer } from '@grafana/ui';
import { GeomapOverlay, OverlayProps } from './GeomapOverlay';
import { DebugOverlay } from './components/DebugOverlay';
import { getGlobalStyles } from './globalStyles';
import { Global } from '@emotion/react';
import { GeomapHoverFeature, GeomapHoverPayload } from './event';
import { DataHoverView } from './components/DataHoverView';
interface MapLayerState {
config: MapLayerOptions;
handler: MapLayerHandler;
layer: BaseLayer; // used to add|remove
}
// Allows multiple panels to share the same view instance
let sharedView: View | undefined = undefined;
export let lastGeomapPanelInstance: GeomapPanel | undefined = undefined;
type Props = PanelProps<GeomapPanelOptions>;
interface State extends OverlayProps {
ttip?: GeomapHoverPayload;
}
export class GeomapPanel extends Component<Props, State> {
globalCSS = getGlobalStyles(config.theme2);
counter = 0;
map?: Map;
basemap?: BaseLayer;
layers: MapLayerState[] = [];
mouseWheelZoom?: MouseWheelZoom;
style = getStyles(config.theme);
hoverPayload: GeomapHoverPayload = { point: {}, pageX: -1, pageY: -1 };
readonly hoverEvent = new DataHoverEvent(this.hoverPayload);
constructor(props: Props) {
super(props);
this.state = {};
}
componentDidMount() {
lastGeomapPanelInstance = this;
}
shouldComponentUpdate(nextProps: Props) {
if (!this.map) {
return true; // not yet initalized
}
// Check for resize
if (this.props.height !== nextProps.height || this.props.width !== nextProps.width) {
this.map.updateSize();
}
// External configuration changed
let layersChanged = false;
if (this.props.options !== nextProps.options) {
layersChanged = this.optionsChanged(nextProps.options);
}
// External data changed
if (layersChanged || this.props.data !== nextProps.data) {
this.dataChanged(nextProps.data);
}
return true; // always?
}
/**
* Called when the panel options change
*/
optionsChanged(options: GeomapPanelOptions): boolean {
let layersChanged = false;
const oldOptions = this.props.options;
console.log('options changed!', options);
if (options.view !== oldOptions.view) {
console.log('View changed');
this.map!.setView(this.initMapView(options.view));
}
if (options.controls !== oldOptions.controls) {
console.log('Controls changed');
this.initControls(options.controls ?? { showZoom: true, showAttribution: true });
}
if (options.basemap !== oldOptions.basemap) {
console.log('Basemap changed');
this.initBasemap(options.basemap);
layersChanged = true;
}
if (options.layers !== oldOptions.layers) {
console.log('layers changed');
this.initLayers(options.layers ?? []); // async
layersChanged = true;
}
return layersChanged;
}
/**
* Called when PanelData changes (query results etc)
*/
dataChanged(data: PanelData) {
for (const state of this.layers) {
if (state.handler.update) {
state.handler.update(data);
}
}
}
initMapRef = async (div: HTMLDivElement) => {
if (this.map) {
this.map.dispose();
}
if (!div) {
this.map = (undefined as unknown) as Map;
return;
}
const { options } = this.props;
this.map = new Map({
view: this.initMapView(options.view),
pixelRatio: 1, // or zoom?
layers: [], // loaded explicitly below
controls: [],
target: div,
interactions: interactionDefaults({
mouseWheelZoom: false, // managed by initControls
}),
});
this.mouseWheelZoom = new MouseWheelZoom();
this.map.addInteraction(this.mouseWheelZoom);
this.initControls(options.controls);
this.initBasemap(options.basemap);
await this.initLayers(options.layers);
this.forceUpdate(); // first render
// Tooltip listener
this.map.on('pointermove', this.pointerMoveListener);
this.map.getViewport().addEventListener('mouseout', (evt) => {
this.props.eventBus.publish(new DataHoverClearEvent({ point: {} }));
});
};
pointerMoveListener = (evt: MapBrowserEvent) => {
if (!this.map) {
return;
}
const mouse = evt.originalEvent as any;
const pixel = this.map.getEventPixel(mouse);
const hover = toLonLat(this.map.getCoordinateFromPixel(pixel));
const { hoverPayload } = this;
hoverPayload.pageX = mouse.pageX;
hoverPayload.pageY = mouse.pageY;
hoverPayload.point = {
lat: hover[1],
lon: hover[0],
};
hoverPayload.data = undefined;
hoverPayload.columnIndex = undefined;
hoverPayload.rowIndex = undefined;
let ttip: GeomapHoverPayload = {} as GeomapHoverPayload;
const features: GeomapHoverFeature[] = [];
this.map.forEachFeatureAtPixel(pixel, (feature, layer, geo) => {
if (!hoverPayload.data) {
const props = feature.getProperties();
const frame = props['frame'];
if (frame) {
hoverPayload.data = ttip.data = frame as DataFrame;
hoverPayload.rowIndex = ttip.rowIndex = props['rowIndex'];
}
}
features.push({ feature, layer, geo });
});
this.hoverPayload.features = features.length ? features : undefined;
this.props.eventBus.publish(this.hoverEvent);
const currentTTip = this.state.ttip;
if (ttip.data !== currentTTip?.data || ttip.rowIndex !== currentTTip?.rowIndex) {
this.setState({ ttip: { ...hoverPayload } });
}
};
async initBasemap(cfg: MapLayerOptions) {
if (!this.map) {
return;
}
if (!cfg?.type || config.geomapDisableCustomBaseLayer) {
cfg = DEFAULT_BASEMAP_CONFIG;
}
const item = geomapLayerRegistry.getIfExists(cfg.type) ?? defaultBaseLayer;
const handler = await item.create(this.map, cfg, config.theme2);
const layer = handler.init();
if (this.basemap) {
this.map.removeLayer(this.basemap);
this.basemap.dispose();
}
this.basemap = layer;
this.map.getLayers().insertAt(0, this.basemap);
}
async initLayers(layers: MapLayerOptions[]) {
// 1st remove existing layers
for (const state of this.layers) {
this.map!.removeLayer(state.layer);
state.layer.dispose();
}
if (!layers) {
layers = [];
}
const legends: React.ReactNode[] = [];
this.layers = [];
for (const overlay of layers) {
const item = geomapLayerRegistry.getIfExists(overlay.type);
if (!item) {
console.warn('unknown layer type: ', overlay);
continue; // TODO -- panel warning?
}
const handler = await item.create(this.map!, overlay, config.theme2);
const layer = handler.init();
(layer as any).___handler = handler;
this.map!.addLayer(layer);
this.layers.push({
config: overlay,
layer,
handler,
});
if (handler.legend) {
legends.push(<div key={`${this.counter++}`}>{handler.legend}</div>);
}
}
this.setState({ bottomLeft: legends });
// Update data after init layers
this.dataChanged(this.props.data);
}
initMapView(config: MapViewConfig): View {
let view = new View({
center: [0, 0],
zoom: 1,
showFullExtent: true, // alows zooming so the full range is visiable
});
// With shared views, all panels use the same view instance
if (config.shared) {
if (!sharedView) {
sharedView = view;
} else {
view = sharedView;
}
}
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 {
console.log('TODO, view requires special handling', v);
}
} 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) {
view.setZoom(config.zoom);
}
return view;
}
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 topRight: ReactNode[] = [];
if (options.showDebug) {
topRight = [<DebugOverlay key="debug" map={this.map} />];
}
this.setState({ topRight });
}
render() {
const { ttip, topRight, bottomLeft } = this.state;
return (
<>
<Global styles={this.globalCSS} />
<div className={this.style.wrap}>
<div className={this.style.map} ref={this.initMapRef}></div>
<GeomapOverlay bottomLeft={bottomLeft} topRight={topRight} />
</div>
<Portal>
{ttip && ttip.data && (
<VizTooltipContainer position={{ x: ttip.pageX, y: ttip.pageY }} offset={{ x: 10, y: 10 }}>
<DataHoverView {...ttip} />
</VizTooltipContainer>
)}
</Portal>
</>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
wrap: css`
position: relative;
width: 100%;
height: 100%;
`,
map: css`
position: absolute;
z-index: 0;
width: 100%;
height: 100%;
`,
}));