import React, { Component } from 'react'; import { geomapLayerRegistry } from './layers/registry'; import { Map, 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 } from '@grafana/data'; import { config } from '@grafana/runtime'; import { ControlsOptions, GeomapPanelOptions, MapViewConfig } from './types'; import { defaultGrafanaThemedMap } from './layers/basemaps'; import { centerPointRegistry, MapCenterID } from './view'; import { fromLonLat } from 'ol/proj'; import { Coordinate } from 'ol/coordinate'; import { css } from '@emotion/css'; import { stylesFactory } from '@grafana/ui'; import { GeomapOverlay, OverlayProps } from './GeomapOverlay'; import { DebugOverlay } from './components/DebugOverlay'; import { getGlobalStyles } from './globalStyles'; import { Global } from '@emotion/react'; 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; type Props = PanelProps; export class GeomapPanel extends Component { globalCSS = getGlobalStyles(config.theme2); map?: Map; basemap?: BaseLayer; layers: MapLayerState[] = []; mouseWheelZoom?: MouseWheelZoom; style = getStyles(config.theme); overlayProps: OverlayProps = {}; 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 configuraiton 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, nextProps.options.controls.showLegend); } 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('Crontrols 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 ?? []); layersChanged = true; } return layersChanged; } /** * Called when PanelData changes (query results etc) */ dataChanged(data: PanelData, showLegend?: boolean) { const legends: React.ReactNode[] = []; for (const state of this.layers) { if (state.handler.update) { state.handler.update(data); } if (showLegend && state.handler.legend) { legends.push(state.handler.legend()); } } this.overlayProps.bottomLeft = legends; } initMapRef = (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); this.initLayers(options.layers); this.dataChanged(this.props.data, options.controls.showLegend); this.forceUpdate(); // first render }; initBasemap(cfg: MapLayerOptions) { if (!this.map) { return; } if (!cfg) { cfg = { type: defaultGrafanaThemedMap.id }; } const item = geomapLayerRegistry.getIfExists(cfg.type) ?? defaultGrafanaThemedMap; const layer = item.create(this.map, cfg, config.theme2).init(); if (this.basemap) { this.map.removeLayer(this.basemap); this.basemap.dispose(); } this.basemap = layer; this.map.getLayers().insertAt(0, this.basemap); } initLayers(layers: MapLayerOptions[]) { // 1st remove existing layers for (const state of this.layers) { this.map!.removeLayer(state.layer); state.layer.dispose(); } if (!layers) { layers = []; } 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 = item.create(this.map!, overlay, config.theme2); const layer = handler.init(); this.map!.addLayer(layer); this.layers.push({ config: overlay, layer, handler, }); } } initMapView(config: MapViewConfig): View { let view = new View({ center: [0, 0], zoom: 1, }); // 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.center.id); if (v) { let coord: Coordinate | undefined = undefined; if (v.lat == null) { if (v.id === MapCenterID.Coordinates) { const center = config.center ?? {}; coord = [center.lon ?? 0, center.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 const overlayProps: OverlayProps = {}; if (options.showDebug) { overlayProps.topRight = []; } this.overlayProps = overlayProps; } render() { return ( <>
); } } const getStyles = stylesFactory((theme: GrafanaTheme) => ({ wrap: css` position: relative; width: 100%; height: 100%; `, map: css` position: absolute; z-index: 0; width: 100%; height: 100%; `, }));