123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682 |
- import { css } from '@emotion/css';
- import { Global } from '@emotion/react';
- import { cloneDeep } from 'lodash';
- import { Collection, Map as OpenLayersMap, MapBrowserEvent, PluggableMap, View } from 'ol';
- import { FeatureLike } from 'ol/Feature';
- import Attribution from 'ol/control/Attribution';
- import ScaleLine from 'ol/control/ScaleLine';
- import Zoom from 'ol/control/Zoom';
- import { Coordinate } from 'ol/coordinate';
- import { createEmpty, extend, isEmpty } from 'ol/extent';
- import { defaults as interactionDefaults } from 'ol/interaction';
- import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
- import BaseLayer from 'ol/layer/Base';
- import VectorLayer from 'ol/layer/Vector';
- import { fromLonLat, toLonLat } from 'ol/proj';
- import React, { Component, ReactNode } from 'react';
- import { Subject, Subscription } from 'rxjs';
- import {
- DataFrame,
- DataHoverClearEvent,
- DataHoverEvent,
- FrameGeometrySourceMode,
- GrafanaTheme,
- MapLayerOptions,
- 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 { GeomapHoverPayload, GeomapLayerHover } from './event';
- import { getGlobalStyles } from './globalStyles';
- import { defaultMarkersConfig, MARKERS_LAYER_ID } from './layers/data/markersLayer';
- import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from './layers/registry';
- import { ControlsOptions, GeomapPanelOptions, MapLayerState, MapViewConfig, TooltipMode } from './types';
- 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[];
- }
- export interface GeomapLayerActions {
- selectLayer: (uid: string) => void;
- deleteLayer: (uid: string) => void;
- addlayer: (type: string) => void;
- reorder: (src: number, dst: number) => void;
- canRename: (v: string) => boolean;
- }
- export interface GeomapInstanceState {
- map?: OpenLayersMap;
- layers: MapLayerState[];
- selected: number;
- actions: GeomapLayerActions;
- }
- export class GeomapPanel extends Component<Props, State> {
- static contextType = PanelContextRoot;
- panelContext: PanelContext = {} as PanelContext;
- 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 as PanelContext;
- }
- 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();
- }
- }
- /** This function will actually update the JSON model */
- private doOptionsUpdate(selected: number) {
- const { options, onOptionsChange } = this.props;
- const layers = this.layers;
- onOptionsChange({
- ...options,
- basemap: layers[0].options,
- layers: layers.slice(1).map((v) => v.options),
- });
- // Notify the panel editor
- if (this.panelContext.onInstanceStateChange) {
- this.panelContext.onInstanceStateChange({
- map: this.map,
- layers: layers,
- selected,
- actions: this.actions,
- });
- }
- this.setState({ legends: this.getLegends() });
- }
- getNextLayerName = () => {
- let idx = this.layers.length; // since basemap is 0, this looks right
- while (true && idx < 100) {
- const name = `Layer ${idx++}`;
- if (!this.byName.has(name)) {
- return name;
- }
- }
- return `Layer ${Date.now()}`;
- };
- actions: GeomapLayerActions = {
- selectLayer: (uid: string) => {
- const selected = this.layers.findIndex((v) => v.options.name === uid);
- if (this.panelContext.onInstanceStateChange) {
- this.panelContext.onInstanceStateChange({
- map: this.map,
- layers: this.layers,
- selected,
- actions: this.actions,
- });
- }
- },
- canRename: (v: string) => {
- return !this.byName.has(v);
- },
- deleteLayer: (uid: string) => {
- const layers: MapLayerState[] = [];
- for (const lyr of this.layers) {
- if (lyr.options.name === uid) {
- this.map?.removeLayer(lyr.layer);
- } else {
- layers.push(lyr);
- }
- }
- this.layers = layers;
- this.doOptionsUpdate(0);
- },
- addlayer: (type: string) => {
- const item = geomapLayerRegistry.getIfExists(type);
- if (!item) {
- return; // ignore empty request
- }
- this.initLayer(
- this.map!,
- {
- type: item.id,
- name: this.getNextLayerName(),
- config: cloneDeep(item.defaultOptions),
- location: item.showLocation ? { mode: FrameGeometrySourceMode.Auto } : undefined,
- tooltip: true,
- },
- false
- ).then((lyr) => {
- this.layers = this.layers.slice(0);
- this.layers.push(lyr);
- this.map?.addLayer(lyr.layer);
- this.doOptionsUpdate(this.layers.length - 1);
- });
- },
- reorder: (startIndex: number, endIndex: number) => {
- const result = Array.from(this.layers);
- const [removed] = result.splice(startIndex, 1);
- result.splice(endIndex, 0, removed);
- this.layers = result;
- this.doOptionsUpdate(endIndex);
- // Add the layers in the right order
- const group = this.map?.getLayers()!;
- group.clear();
- this.layers.forEach((v) => group.push(v.layer));
- },
- };
- /**
- * Called when the panel options change
- *
- * NOTE: changes to basemap and layers are handled independently
- */
- optionsChanged(options: GeomapPanelOptions) {
- 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, this.map!.getLayers()));
- }
- if (options.controls !== oldOptions.controls) {
- console.log('Controls changed');
- this.initControls(options.controls ?? { showZoom: true, showAttribution: true });
- }
- }
- /**
- * 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) => {
- this.mapDiv = div;
- if (this.map) {
- this.map.dispose();
- }
- if (!div) {
- this.map = undefined as unknown as OpenLayersMap;
- return;
- }
- const { options } = this.props;
- const map = (this.map = new OpenLayersMap({
- view: this.initMapView(options.view, undefined),
- pixelRatio: 1, // or zoom?
- layers: [], // loaded explicitly below
- controls: [],
- target: div,
- interactions: interactionDefaults({
- mouseWheelZoom: false, // managed by initControls
- }),
- }));
- this.byName.clear();
- const layers: MapLayerState[] = [];
- try {
- layers.push(await this.initLayer(map, options.basemap ?? DEFAULT_BASEMAP_CONFIG, true));
- // Default layer values
- const layerOptions = options.layers ?? [defaultMarkersConfig];
- for (const lyr of layerOptions) {
- layers.push(await this.initLayer(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, map.getLayers());
- this.mouseWheelZoom = new MouseWheelZoom();
- this.map.addInteraction(this.mouseWheelZoom);
- this.initControls(options.controls);
- this.forceUpdate(); // first render
- // Tooltip listener
- this.map.on('singleclick', this.pointerClickListener);
- this.map.on('pointermove', this.pointerMoveListener);
- this.map.getViewport().addEventListener('mouseout', (evt) => {
- this.props.eventBus.publish(new DataHoverClearEvent());
- });
- // Notify the panel editor
- if (this.panelContext.onInstanceStateChange) {
- this.panelContext.onInstanceStateChange({
- map: this.map,
- layers: layers,
- selected: layers.length - 1, // the top layer
- actions: this.actions,
- });
- }
- 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>) => {
- if (this.pointerMoveListener(evt)) {
- evt.preventDefault();
- evt.stopPropagation();
- this.mapDiv!.style.cursor = 'auto';
- this.setState({ ttipOpen: true });
- }
- };
- pointerMoveListener = (evt: MapBrowserEvent<UIEvent>) => {
- if (!this.map || this.state.ttipOpen) {
- return false;
- }
- 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.layers = undefined;
- const layers: GeomapLayerHover[] = [];
- const layerLookup = new Map<MapLayerState, GeomapLayerHover>();
- let ttip: GeomapHoverPayload = {} as GeomapHoverPayload;
- this.map.forEachFeatureAtPixel(
- pixel,
- (feature, layer, geo) => {
- const s: MapLayerState = (layer as any).__state;
- //match hover layer to layer in layers
- //check if the layer show tooltip is enabled
- //then also pass the list of tooltip fields if exists
- //this is used as the generic hover event
- 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'];
- }
- if (s?.mouseEvents) {
- s.mouseEvents.next(feature);
- }
- }
- if (s) {
- let h = layerLookup.get(s);
- if (!h) {
- h = { layer: s, features: [] };
- layerLookup.set(s, h);
- layers.push(h);
- }
- h.features.push(feature);
- }
- },
- {
- layerFilter: (l) => {
- const hoverLayerState = (l as any).__state as MapLayerState;
- return hoverLayerState.options.tooltip !== false;
- },
- }
- );
- this.hoverPayload.layers = layers.length ? layers : undefined;
- this.props.eventBus.publish(this.hoverEvent);
- this.setState({ ttip: { ...hoverPayload } });
- if (!layers.length) {
- // clear mouse events
- this.layers.forEach((layer) => {
- layer.mouseEvents.next(undefined);
- });
- }
- const found = layers.length ? true : false;
- this.mapDiv!.style.cursor = found ? 'pointer' : 'auto';
- return found;
- };
- private updateLayer = async (uid: string, newOptions: MapLayerOptions): Promise<boolean> => {
- if (!this.map) {
- return false;
- }
- const current = this.byName.get(uid);
- if (!current) {
- return false;
- }
- let layerIndex = -1;
- const group = this.map?.getLayers()!;
- for (let i = 0; i < group?.getLength(); i++) {
- if (group.item(i) === current.layer) {
- layerIndex = i;
- break;
- }
- }
- // Special handling for rename
- if (newOptions.name !== uid) {
- if (!newOptions.name) {
- newOptions.name = uid;
- } else if (this.byName.has(newOptions.name)) {
- return false;
- }
- console.log('Layer name changed', uid, '>>>', newOptions.name);
- this.byName.delete(uid);
- uid = newOptions.name;
- this.byName.set(uid, current);
- }
- // Type changed -- requires full re-initalization
- if (current.options.type !== newOptions.type) {
- // full init
- } else {
- // just update options
- }
- const layers = this.layers.slice(0);
- try {
- const info = await this.initLayer(this.map, newOptions, current.isBasemap);
- layers[layerIndex] = info;
- group.setAt(layerIndex, info.layer);
- // initialize with new data
- if (info.handler.update) {
- info.handler.update(this.props.data);
- }
- } catch (err) {
- console.warn('ERROR', err);
- return false;
- }
- // Just to trigger a state update
- this.setState({ legends: [] });
- this.layers = layers;
- this.doOptionsUpdate(layerIndex);
- return true;
- };
- async initLayer(map: PluggableMap, options: MapLayerOptions, isBasemap?: boolean): Promise<MapLayerState> {
- if (isBasemap && (!options?.type || config.geomapDisableCustomBaseLayer)) {
- options = DEFAULT_BASEMAP_CONFIG;
- }
- // Use default makers layer
- if (!options?.type) {
- options = {
- type: MARKERS_LAYER_ID,
- name: this.getNextLayerName(),
- config: {},
- };
- }
- const item = geomapLayerRegistry.getIfExists(options.type);
- if (!item) {
- return Promise.reject('unknown layer: ' + options.type);
- }
- const handler = await item.create(map, options, config.theme2);
- const layer = handler.init();
- if (handler.update) {
- handler.update(this.props.data);
- }
- if (!options.name) {
- options.name = this.getNextLayerName();
- }
- const UID = options.name;
- const state: MapLayerState<any> = {
- // UID, // unique name when added to the map (it may change and will need special handling)
- isBasemap,
- options,
- layer,
- handler,
- mouseEvents: new Subject<FeatureLike | undefined>(),
- getName: () => UID,
- // Used by the editors
- onChange: (cfg: MapLayerOptions) => {
- this.updateLayer(UID, cfg);
- },
- };
- this.byName.set(UID, state);
- (state.layer as any).__state = state;
- return state;
- }
- initMapView(config: MapViewConfig, layers?: Collection<BaseLayer>): View {
- 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;
- }
- }
- if (layers) {
- this.initViewExtent(view, config, layers);
- }
- return view;
- }
- initViewExtent(view: View, config: MapViewConfig, layers: Collection<BaseLayer>) {
- 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) {
- var extent = layers
- .getArray()
- .filter((l) => l instanceof VectorLayer)
- .map((l) => (l as VectorLayer<any>).getSource().getExtent() ?? [])
- .reduce(extend, createEmpty());
- if (!isEmpty(extent)) {
- view.fit(extent, {
- padding: [30, 30, 30, 30],
- maxZoom: config.zoom ?? config.maxZoom,
- });
- }
- } 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 && 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 topRight: ReactNode[] = [];
- if (options.showDebug) {
- topRight = [<DebugOverlay key="debug" map={this.map} />];
- }
- this.setState({ topRight });
- }
- 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, topRight, legends } = 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} topRight={topRight} 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%;
- `,
- }));
|