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; interface State extends OverlayProps { ttip?: GeomapHoverPayload; } export class GeomapPanel extends Component { 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; hoverPayload.feature = 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']; } else { hoverPayload.feature = ttip.feature = feature; } } 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 || ttip.feature !== currentTTip?.feature ) { 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(
{handler.legend}
); } } 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 = []; } this.setState({ topRight }); } render() { const { ttip, topRight, bottomLeft } = this.state; return ( <>
{ttip && (ttip.data || ttip.feature) && ( )} ); } } const getStyles = stylesFactory((theme: GrafanaTheme) => ({ wrap: css` position: relative; width: 100%; height: 100%; `, map: css` position: absolute; z-index: 0; width: 100%; height: 100%; `, }));