diff --git a/.betterer.results b/.betterer.results index 8a34bf22c87..1746f2acc6d 100644 --- a/.betterer.results +++ b/.betterer.results @@ -8088,33 +8088,12 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/plugins/panel/geomap/GeomapPanel.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Do not use any type assertions.", "8"], - [0, 0, 0, "Do not use any type assertions.", "9"], - [0, 0, 0, "Do not use any type assertions.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"], - [0, 0, 0, "Do not use any type assertions.", "12"], - [0, 0, 0, "Unexpected any. Specify a different type.", "13"] - ], - "public/app/plugins/panel/geomap/components/DataHoverRows.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/panel/geomap/components/MarkersLegend.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"] + [0, 0, 0, "Do not use any type assertions.", "4"] ], "public/app/plugins/panel/geomap/components/MeasureVectorLayer.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -8169,15 +8148,20 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], - "public/app/plugins/panel/geomap/module.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/plugins/panel/geomap/style/utils.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/plugins/panel/geomap/utils/layers.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] + ], "public/app/plugins/panel/geomap/utils/selection.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/plugins/panel/geomap/utils/tootltip.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] + ], "public/app/plugins/panel/geomap/view.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index 20f83337713..fc2fdf12141 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -1,32 +1,14 @@ 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 { 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 { defaults as interactionDefaults } from 'ol/interaction'; import MouseWheelZoom from 'ol/interaction/MouseWheelZoom'; -import BaseLayer from 'ol/layer/Base'; -import { fromLonLat, toLonLat } from 'ol/proj'; import React, { Component, ReactNode } from 'react'; -import { Subject, Subscription } from 'rxjs'; +import { Subscription } from 'rxjs'; -import { - DataFrame, - DataHoverClearEvent, - DataHoverEvent, - FrameGeometrySourceMode, - getFrameMatchers, - GrafanaTheme, - MapLayerHandler, - MapLayerOptions, - PanelData, - PanelProps, -} from '@grafana/data'; +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'; @@ -36,13 +18,16 @@ import { GeomapTooltip } from './GeomapTooltip'; import { DebugOverlay } from './components/DebugOverlay'; import { MeasureOverlay } from './components/MeasureOverlay'; import { MeasureVectorLayer } from './components/MeasureVectorLayer'; -import { GeomapHoverPayload, GeomapLayerHover } from './event'; +import { GeomapHoverPayload } 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 { getLayersExtent } from './utils/getLayersExtent'; -import { centerPointRegistry, MapCenterID } from './view'; +import { defaultMarkersConfig } from './layers/data/markersLayer'; +import { DEFAULT_BASEMAP_CONFIG } from './layers/registry'; +import { ControlsOptions, GeomapPanelOptions, MapLayerState, TooltipMode } from './types'; +import { getActions } from './utils/actions'; +import { applyLayerFilter, initLayer } from './utils/layers'; +import { pointerClickListener, pointerMoveListener, setTooltipListeners } from './utils/tootltip'; +import { updateMap, getNewOpenLayersMap, notifyPanelEditor } from './utils/utils'; +import { initMapView, initViewExtent } from './utils/view'; // Allows multiple panels to share the same view instance let sharedView: View | undefined = undefined; @@ -55,24 +40,9 @@ interface State extends OverlayProps { measureMenuActive?: boolean; } -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 { static contextType = PanelContextRoot; - panelContext: PanelContext = {} as PanelContext; + panelContext: PanelContext | undefined = undefined; private subs = new Subscription(); globalCSS = getGlobalStyles(config.theme2); @@ -100,7 +70,7 @@ export class GeomapPanel extends Component { } componentDidMount() { - this.panelContext = this.context as PanelContext; + this.panelContext = this.context; } componentWillUnmount() { @@ -144,7 +114,7 @@ export class GeomapPanel extends Component { } /** This function will actually update the JSON model */ - private doOptionsUpdate(selected: number) { + doOptionsUpdate(selected: number) { const { options, onOptionsChange } = this.props; const layers = this.layers; this.map?.getLayers().forEach((l) => { @@ -159,95 +129,11 @@ export class GeomapPanel extends Component { 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, - }); - } - + notifyPanelEditor(this, layers, selected); 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) => { - // TODO look into reorder with respect to measure layer - 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)); - }, - }; + actions = getActions(this); /** * Called when the panel options change @@ -257,7 +143,10 @@ export class GeomapPanel extends Component { optionsChanged(options: GeomapPanelOptions) { const oldOptions = this.props.options; if (options.view !== oldOptions.view) { - this.map!.setView(this.initMapView(options.view, this.map!.getLayers())); + const [updatedSharedView, view] = initMapView(options.view, sharedView, this.map!.getLayers()); + sharedView = updatedSharedView; + // eslint-disable-next-line + this.map!.setView(view as View); } if (options.controls !== oldOptions.controls) { @@ -272,7 +161,7 @@ export class GeomapPanel extends Component { // Only update if panel data matches component data if (data === this.props.data) { for (const state of this.layers) { - this.applyLayerFilter(state.handler, state.options); + applyLayerFilter(state.handler, state.options, this.props.data); } } } @@ -284,35 +173,27 @@ export class GeomapPanel extends Component { } if (!div) { + // eslint-disable-next-line 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 - }), - })); + const map = getNewOpenLayersMap(this, options, div); this.byName.clear(); const layers: MapLayerState[] = []; try { - layers.push(await this.initLayer(map, options.basemap ?? DEFAULT_BASEMAP_CONFIG, true)); + layers.push(await initLayer(this, 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)); + layers.push(await initLayer(this, map, lyr, false)); } } catch (ex) { - console.error('error loading layers', ex); + console.error('error loading layers', ex); // eslint-disable-line no-console } for (const lyr of layers) { @@ -320,29 +201,14 @@ export class GeomapPanel extends Component { } this.layers = layers; this.map = map; // redundant - this.initViewExtent(map.getView(), options.view, map.getLayers()); + initViewExtent(map.getView(), options.view, map.getLayers()); this.mouseWheelZoom = new MouseWheelZoom(); - this.map.addInteraction(this.mouseWheelZoom); - this.initControls(options.controls); - this.forceUpdate(); // first render + this.map?.addInteraction(this.mouseWheelZoom); - // 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, - }); - } + updateMap(this, options); + setTooltipListeners(this); + notifyPanelEditor(this, layers, layers.length - 1); this.setState({ legends: this.getLegends() }); }; @@ -358,283 +224,13 @@ export class GeomapPanel extends Component { }; pointerClickListener = (evt: MapBrowserEvent) => { - if (this.pointerMoveListener(evt)) { - evt.preventDefault(); - evt.stopPropagation(); - this.mapDiv!.style.cursor = 'auto'; - this.setState({ ttipOpen: true }); - } + pointerClickListener(evt, this); }; pointerMoveListener = (evt: MapBrowserEvent) => { - // If measure menu is open, bypass tooltip logic and display measuring mouse events - if (this.state.measureMenuActive) { - return true; - } - if (!this.map || this.state.ttipOpen) { - return false; - } - const mouse = evt.originalEvent as MouseEvent; - 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(); - - 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 = Boolean(layers.length); - this.mapDiv!.style.cursor = found ? 'pointer' : 'auto'; - return found; + pointerMoveListener(evt, this); }; - private updateLayer = async (uid: string, newOptions: MapLayerOptions): Promise => { - 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; - } - 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]?.handler.dispose?.(); - layers[layerIndex] = info; - group.setAt(layerIndex, info.layer); - - // initialize with new data - this.applyLayerFilter(info.handler, newOptions); - } 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 { - 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, this.props.eventBus, config.theme2); - const layer = handler.init(); - if (options.opacity != null) { - layer.setOpacity(options.opacity); - } - - if (!options.name) { - options.name = this.getNextLayerName(); - } - - const UID = options.name; - const state: MapLayerState = { - // UID, // unique name when added to the map (it may change and will need special handling) - isBasemap, - options, - layer, - handler, - mouseEvents: new Subject(), - - getName: () => UID, - - // Used by the editors - onChange: (cfg: MapLayerOptions) => { - this.updateLayer(UID, cfg); - }, - }; - - this.byName.set(UID, state); - (state.layer as any).__state = state; - - this.applyLayerFilter(handler, options); - - return state; - } - - applyLayerFilter(handler: MapLayerHandler, options: MapLayerOptions): void { - if (handler.update) { - let panelData = this.props.data; - if (options.filterData) { - const matcherFunc = getFrameMatchers(options.filterData); - panelData = { - ...panelData, - series: panelData.series.filter(matcherFunc), - }; - } - handler.update(panelData); - } - } - - initMapView(config: MapViewConfig, layers?: Collection): 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) { - 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(layers); - if (!isEmpty(extent)) { - view.fit(extent, { - padding: [30, 30, 30, 30], - maxZoom: config.zoom ?? config.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; diff --git a/public/app/plugins/panel/geomap/components/DataHoverRows.tsx b/public/app/plugins/panel/geomap/components/DataHoverRows.tsx index aac2f77dc8d..3a392e7da31 100644 --- a/public/app/plugins/panel/geomap/components/DataHoverRows.tsx +++ b/public/app/plugins/panel/geomap/components/DataHoverRows.tsx @@ -63,7 +63,7 @@ export const generateLabel = (feature: FeatureLike, idx: number): string => { const names = ['Name', 'name', 'Title', 'ID', 'id']; let props = feature.getProperties(); let first = ''; - const frame = feature.get('frame') as DataFrame; + const frame = feature.get('frame') as DataFrame; // eslint-disable-line if (frame) { const rowIndex = feature.get('rowIndex'); for (const f of frame.fields) { diff --git a/public/app/plugins/panel/geomap/components/MarkersLegend.tsx b/public/app/plugins/panel/geomap/components/MarkersLegend.tsx index 444e6232ff4..523c6c6ae3b 100644 --- a/public/app/plugins/panel/geomap/components/MarkersLegend.tsx +++ b/public/app/plugins/panel/geomap/components/MarkersLegend.tsx @@ -36,13 +36,13 @@ export function MarkersLegend(props: MarkersLegendProps) { } const props = hoverEvent.getProperties(); - const frame = props.frame as DataFrame; + const frame = props.frame as DataFrame; // eslint-disable-line if (!frame) { return undefined; } - const rowIndex = props.rowIndex as number; + const rowIndex = props.rowIndex as number; // eslint-disable-line return colorField.values.get(rowIndex); }, [hoverEvent, colorField]); diff --git a/public/app/plugins/panel/geomap/editor/LayersEditor.tsx b/public/app/plugins/panel/geomap/editor/LayersEditor.tsx index 26778ba4fee..52908a98c6a 100644 --- a/public/app/plugins/panel/geomap/editor/LayersEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/LayersEditor.tsx @@ -6,9 +6,8 @@ import { Container } from '@grafana/ui'; import { AddLayerButton } from 'app/core/components/Layers/AddLayerButton'; import { LayerDragDropList } from 'app/core/components/Layers/LayerDragDropList'; -import { GeomapInstanceState } from '../GeomapPanel'; import { getLayersOptions } from '../layers/registry'; -import { GeomapPanelOptions, MapLayerState } from '../types'; +import { GeomapPanelOptions, MapLayerState, GeomapInstanceState } from '../types'; type LayersEditorProps = StandardEditorProps; diff --git a/public/app/plugins/panel/geomap/editor/MapViewEditor.tsx b/public/app/plugins/panel/geomap/editor/MapViewEditor.tsx index 2db2e6bb40b..4029a8734c5 100644 --- a/public/app/plugins/panel/geomap/editor/MapViewEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/MapViewEditor.tsx @@ -5,8 +5,7 @@ import { StandardEditorProps, SelectableValue } from '@grafana/data'; import { Button, InlineField, InlineFieldRow, Select, VerticalGroup } from '@grafana/ui'; import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; -import { GeomapInstanceState } from '../GeomapPanel'; -import { GeomapPanelOptions, MapViewConfig } from '../types'; +import { GeomapPanelOptions, MapViewConfig, GeomapInstanceState } from '../types'; import { centerPointRegistry, MapCenterID } from '../view'; export const MapViewEditor: FC< diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx index 6639f0c421e..9a9a9e3738d 100644 --- a/public/app/plugins/panel/geomap/module.tsx +++ b/public/app/plugins/panel/geomap/module.tsx @@ -4,12 +4,12 @@ import { PanelPlugin } from '@grafana/data'; import { config } from '@grafana/runtime'; import { commonOptionsBuilder } from '@grafana/ui'; -import { GeomapInstanceState, GeomapPanel } from './GeomapPanel'; +import { GeomapPanel } from './GeomapPanel'; import { LayersEditor } from './editor/LayersEditor'; import { MapViewEditor } from './editor/MapViewEditor'; import { getLayerEditor } from './editor/layerEditor'; import { mapPanelChangedHandler, mapMigrationHandler } from './migrations'; -import { defaultView, GeomapPanelOptions, TooltipMode } from './types'; +import { defaultView, GeomapPanelOptions, TooltipMode, GeomapInstanceState } from './types'; export const plugin = new PanelPlugin(GeomapPanel) .setNoPadding() @@ -40,6 +40,7 @@ export const plugin = new PanelPlugin(GeomapPanel) defaultValue: defaultView.shared, }); + // eslint-disable-next-line const state = context.instanceState as GeomapInstanceState; if (!state?.layers) { // TODO? show spinner? diff --git a/public/app/plugins/panel/geomap/style/markers.ts b/public/app/plugins/panel/geomap/style/markers.ts index 2ec1f68294b..9dd330565e7 100644 --- a/public/app/plugins/panel/geomap/style/markers.ts +++ b/public/app/plugins/panel/geomap/style/markers.ts @@ -267,7 +267,7 @@ async function prepareSVG(url: string, size?: number): Promise { return `data:image/svg+xml,${svgURI}`; }) .catch((error) => { - console.error(error); + console.error(error); // eslint-disable-line no-console return ''; }); } diff --git a/public/app/plugins/panel/geomap/types.ts b/public/app/plugins/panel/geomap/types.ts index 7a3cb4b3ce2..0bc502bb622 100644 --- a/public/app/plugins/panel/geomap/types.ts +++ b/public/app/plugins/panel/geomap/types.ts @@ -1,3 +1,4 @@ +import { Map as OpenLayersMap } from 'ol'; import { FeatureLike } from 'ol/Feature'; import BaseLayer from 'ol/layer/Base'; import Units from 'ol/proj/Units'; @@ -69,16 +70,33 @@ export interface GeomapPanelOptions { layers: MapLayerOptions[]; tooltip: TooltipOptions; } + export interface FeatureStyleConfig { style?: StyleConfig; check?: FeatureRuleConfig; } + export interface FeatureRuleConfig { property: string; operation: ComparisonOperation; value: string | boolean | number; } +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 enum ComparisonOperation { EQ = 'eq', NEQ = 'neq', diff --git a/public/app/plugins/panel/geomap/utils/actions.ts b/public/app/plugins/panel/geomap/utils/actions.ts new file mode 100644 index 00000000000..52e4530de49 --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/actions.ts @@ -0,0 +1,80 @@ +import { cloneDeep } from 'lodash'; + +import { FrameGeometrySourceMode } from '@grafana/data/src'; + +import { GeomapPanel } from '../GeomapPanel'; +import { geomapLayerRegistry } from '../layers/registry'; +import { GeomapLayerActions, MapLayerState } from '../types'; + +import { initLayer } from './layers'; +import { getNextLayerName } from './utils'; + +export const getActions = (panel: GeomapPanel) => { + const actions: GeomapLayerActions = { + selectLayer: (uid: string) => { + const selected = panel.layers.findIndex((v) => v.options.name === uid); + if (panel.panelContext && panel.panelContext.onInstanceStateChange) { + panel.panelContext.onInstanceStateChange({ + map: panel.map, + layers: panel.layers, + selected, + actions: panel.actions, + }); + } + }, + canRename: (v: string) => { + return !panel.byName.has(v); + }, + deleteLayer: (uid: string) => { + const layers: MapLayerState[] = []; + for (const lyr of panel.layers) { + if (lyr.options.name === uid) { + panel.map?.removeLayer(lyr.layer); + } else { + layers.push(lyr); + } + } + panel.layers = layers; + panel.doOptionsUpdate(0); + }, + addlayer: (type: string) => { + const item = geomapLayerRegistry.getIfExists(type); + if (!item) { + return; // ignore empty request + } + initLayer( + panel, + panel.map!, + { + type: item.id, + name: getNextLayerName(panel), + config: cloneDeep(item.defaultOptions), + location: item.showLocation ? { mode: FrameGeometrySourceMode.Auto } : undefined, + tooltip: true, + }, + false + ).then((lyr) => { + panel.layers = panel.layers.slice(0); + panel.layers.push(lyr); + panel.map?.addLayer(lyr.layer); + + panel.doOptionsUpdate(panel.layers.length - 1); + }); + }, + reorder: (startIndex: number, endIndex: number) => { + const result = Array.from(panel.layers); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + panel.layers = result; + + panel.doOptionsUpdate(endIndex); + + // Add the layers in the right order + const group = panel.map?.getLayers()!; + group.clear(); + panel.layers.forEach((v) => group.push(v.layer)); + }, + }; + + return actions; +}; diff --git a/public/app/plugins/panel/geomap/utils/layers.ts b/public/app/plugins/panel/geomap/utils/layers.ts new file mode 100644 index 00000000000..4cd68d22a5d --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/layers.ts @@ -0,0 +1,155 @@ +import { PluggableMap } from 'ol'; +import { FeatureLike } from 'ol/Feature'; +import { Subject } from 'rxjs'; + +import { getFrameMatchers, MapLayerHandler, MapLayerOptions, PanelData } from '@grafana/data/src'; +import { config } from '@grafana/runtime/src'; + +import { GeomapPanel } from '../GeomapPanel'; +import { MARKERS_LAYER_ID } from '../layers/data/markersLayer'; +import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from '../layers/registry'; +import { MapLayerState } from '../types'; + +import { getNextLayerName } from './utils'; + +export const applyLayerFilter = ( + handler: MapLayerHandler, + options: MapLayerOptions, + panelDataProps: PanelData +): void => { + if (handler.update) { + let panelData = panelDataProps; + if (options.filterData) { + const matcherFunc = getFrameMatchers(options.filterData); + panelData = { + ...panelData, + series: panelData.series.filter(matcherFunc), + }; + } + handler.update(panelData); + } +}; + +export async function updateLayer(panel: GeomapPanel, uid: string, newOptions: MapLayerOptions): Promise { + if (!panel.map) { + return false; + } + const current = panel.byName.get(uid); + if (!current) { + return false; + } + + let layerIndex = -1; + const group = panel.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 (panel.byName.has(newOptions.name)) { + return false; + } + panel.byName.delete(uid); + + uid = newOptions.name; + panel.byName.set(uid, current); + } + + // Type changed -- requires full re-initalization + if (current.options.type !== newOptions.type) { + // full init + } else { + // just update options + } + + const layers = panel.layers.slice(0); + try { + const info = await initLayer(panel, panel.map, newOptions, current.isBasemap); + layers[layerIndex]?.handler.dispose?.(); + layers[layerIndex] = info; + group.setAt(layerIndex, info.layer); + + // initialize with new data + applyLayerFilter(info.handler, newOptions, panel.props.data); + } catch (err) { + console.warn('ERROR', err); // eslint-disable-line no-console + return false; + } + + // Just to trigger a state update + panel.setState({ legends: [] }); + + panel.layers = layers; + panel.doOptionsUpdate(layerIndex); + return true; +} + +export async function initLayer( + panel: GeomapPanel, + map: PluggableMap, + options: MapLayerOptions, + isBasemap?: boolean +): Promise { + if (isBasemap && (!options?.type || config.geomapDisableCustomBaseLayer)) { + options = DEFAULT_BASEMAP_CONFIG; + } + + // Use default makers layer + if (!options?.type) { + options = { + type: MARKERS_LAYER_ID, + name: getNextLayerName(panel), + config: {}, + }; + } + + const item = geomapLayerRegistry.getIfExists(options.type); + if (!item) { + return Promise.reject('unknown layer: ' + options.type); + } + + const handler = await item.create(map, options, panel.props.eventBus, config.theme2); + const layer = handler.init(); // eslint-disable-line + if (options.opacity != null) { + layer.setOpacity(options.opacity); + } + + if (!options.name) { + options.name = getNextLayerName(panel); + } + + const UID = options.name; + const state: MapLayerState = { + // UID, // unique name when added to the map (it may change and will need special handling) + isBasemap, + options, + layer, + handler, + mouseEvents: new Subject(), + + getName: () => UID, + + // Used by the editors + onChange: (cfg: MapLayerOptions) => { + updateLayer(panel, UID, cfg); + }, + }; + + panel.byName.set(UID, state); + // eslint-disable-next-line + (state.layer as any).__state = state; + + applyLayerFilter(handler, options, panel.props.data); + + return state; +} + +export const getMapLayerState = (l: any) => { + return l?.__state as MapLayerState; +}; diff --git a/public/app/plugins/panel/geomap/utils/tootltip.ts b/public/app/plugins/panel/geomap/utils/tootltip.ts new file mode 100644 index 00000000000..c67643ba750 --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/tootltip.ts @@ -0,0 +1,111 @@ +import { MapBrowserEvent } from 'ol'; +import { toLonLat } from 'ol/proj'; + +import { DataFrame, DataHoverClearEvent } from '@grafana/data/src'; + +import { GeomapPanel } from '../GeomapPanel'; +import { GeomapHoverPayload, GeomapLayerHover } from '../event'; +import { MapLayerState } from '../types'; + +import { getMapLayerState } from './layers'; + +export const setTooltipListeners = (panel: GeomapPanel) => { + // Tooltip listener + panel.map?.on('singleclick', panel.pointerClickListener); + panel.map?.on('pointermove', panel.pointerMoveListener); + panel.map?.getViewport().addEventListener('mouseout', (evt: MouseEvent) => { + panel.props.eventBus.publish(new DataHoverClearEvent()); + }); +}; + +export const pointerClickListener = (evt: MapBrowserEvent, panel: GeomapPanel) => { + if (pointerMoveListener(evt, panel)) { + evt.preventDefault(); + evt.stopPropagation(); + panel.mapDiv!.style.cursor = 'auto'; + panel.setState({ ttipOpen: true }); + } +}; + +export const pointerMoveListener = (evt: MapBrowserEvent, panel: GeomapPanel) => { + // If measure menu is open, bypass tooltip logic and display measuring mouse events + if (panel.state.measureMenuActive) { + return true; + } + if (!panel.map || panel.state.ttipOpen) { + return false; + } + const mouse = evt.originalEvent as MouseEvent; // eslint-disable-line + const pixel = panel.map.getEventPixel(mouse); + const hover = toLonLat(panel.map.getCoordinateFromPixel(pixel)); + + const { hoverPayload } = panel; + 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(); + + let ttip: GeomapHoverPayload = {} as GeomapHoverPayload; + panel.map.forEachFeatureAtPixel( + pixel, + (feature, layer, geo) => { + const s: MapLayerState = getMapLayerState(layer); + //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 = getMapLayerState(l); + return hoverLayerState?.options?.tooltip !== false; + }, + } + ); + panel.hoverPayload.layers = layers.length ? layers : undefined; + panel.props.eventBus.publish(panel.hoverEvent); + + panel.setState({ ttip: { ...hoverPayload } }); + + if (!layers.length) { + // clear mouse events + panel.layers.forEach((layer) => { + layer.mouseEvents.next(undefined); + }); + } + + const found = Boolean(layers.length); + panel.mapDiv!.style.cursor = found ? 'pointer' : 'auto'; + return found; +}; diff --git a/public/app/plugins/panel/geomap/utils/utils.ts b/public/app/plugins/panel/geomap/utils/utils.ts index f3f373035c7..e9a66162676 100644 --- a/public/app/plugins/panel/geomap/utils/utils.ts +++ b/public/app/plugins/panel/geomap/utils/utils.ts @@ -1,9 +1,16 @@ +import { Map as OpenLayersMap } from 'ol'; +import { defaults as interactionDefaults } from 'ol/interaction'; + import { SelectableValue } from '@grafana/data'; import { DataFrame, GrafanaTheme2 } from '@grafana/data/src'; import { getColorDimension, getScalarDimension, getScaledDimension, getTextDimension } from 'app/features/dimensions'; import { getGrafanaDatasource } from 'app/plugins/datasource/grafana/datasource'; +import { GeomapPanel } from '../GeomapPanel'; import { defaultStyleConfig, StyleConfig, StyleConfigState, StyleDimensions } from '../style/types'; +import { GeomapPanelOptions, MapLayerState } from '../types'; + +import { initMapView } from './view'; export function getStyleDimension( frame: DataFrame | undefined, @@ -68,3 +75,46 @@ async function initGeojsonFiles() { }); } } + +export const getNewOpenLayersMap = (panel: GeomapPanel, options: GeomapPanelOptions, div: HTMLDivElement) => { + const [view] = initMapView(options.view, undefined, undefined); + return (panel.map = new OpenLayersMap({ + view: view, + pixelRatio: 1, // or zoom? + layers: [], // loaded explicitly below + controls: [], + target: div, + interactions: interactionDefaults({ + mouseWheelZoom: false, // managed by initControls + }), + })); +}; + +export const updateMap = (panel: GeomapPanel, options: GeomapPanelOptions) => { + panel.initControls(options.controls); + panel.forceUpdate(); // first render +}; + +export const notifyPanelEditor = (geomapPanel: GeomapPanel, layers: MapLayerState[], selected: number) => { + // Notify the panel editor + if (geomapPanel.panelContext && geomapPanel.panelContext.onInstanceStateChange) { + geomapPanel.panelContext.onInstanceStateChange({ + map: geomapPanel.map, + layers: layers, + selected: selected, + actions: geomapPanel.actions, + }); + } +}; + +export const getNextLayerName = (panel: GeomapPanel) => { + let idx = panel.layers.length; // since basemap is 0, this looks right + while (true && idx < 100) { + const name = `Layer ${idx++}`; + if (!panel.byName.has(name)) { + return name; + } + } + + return `Layer ${Date.now()}`; +}; diff --git a/public/app/plugins/panel/geomap/utils/view.ts b/public/app/plugins/panel/geomap/utils/view.ts new file mode 100644 index 00000000000..dcbdf5d14dc --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/view.ts @@ -0,0 +1,73 @@ +import { View, Collection } from 'ol'; +import { Coordinate } from 'ol/coordinate'; +import { isEmpty } from 'ol/extent'; +import BaseLayer from 'ol/layer/Base'; +import { fromLonLat } from 'ol/proj'; + +import { MapViewConfig } from '../types'; +import { centerPointRegistry, MapCenterID } from '../view'; + +import { getLayersExtent } from './getLayersExtent'; + +export const initViewExtent = (view: View, config: MapViewConfig, layers: Collection) => { + 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(layers); + if (!isEmpty(extent)) { + view.fit(extent, { + padding: [30, 30, 30, 30], + maxZoom: config.zoom ?? config.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); + } +}; + +export const initMapView = ( + config: MapViewConfig, + sharedView?: View | undefined, + layers?: Collection +): Array => { + 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) { + initViewExtent(view, config, layers); + } + + return [sharedView, view]; +};