diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index fc2fdf12141..2ec74c4ec25 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -4,7 +4,10 @@ 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 MouseWheelZoom from 'ol/interaction/MouseWheelZoom'; +import { fromLonLat } from 'ol/proj'; import React, { Component, ReactNode } from 'react'; import { Subscription } from 'rxjs'; @@ -22,12 +25,13 @@ import { GeomapHoverPayload } from './event'; import { getGlobalStyles } from './globalStyles'; import { defaultMarkersConfig } from './layers/data/markersLayer'; import { DEFAULT_BASEMAP_CONFIG } from './layers/registry'; -import { ControlsOptions, GeomapPanelOptions, MapLayerState, TooltipMode } from './types'; +import { ControlsOptions, GeomapPanelOptions, MapLayerState, MapViewConfig, TooltipMode } from './types'; import { getActions } from './utils/actions'; +import { getLayersExtent } from './utils/getLayersExtent'; 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'; +import { centerPointRegistry, MapCenterID } from './view'; // Allows multiple panels to share the same view instance let sharedView: View | undefined = undefined; @@ -143,10 +147,12 @@ export class GeomapPanel extends Component { optionsChanged(options: GeomapPanelOptions) { const oldOptions = this.props.options; if (options.view !== oldOptions.view) { - const [updatedSharedView, view] = initMapView(options.view, sharedView, this.map!.getLayers()); + const [updatedSharedView, view] = this.initMapView(options.view, sharedView); sharedView = updatedSharedView; - // eslint-disable-next-line - this.map!.setView(view as View); + + if (this.map && view) { + this.map.setView(view); + } } if (options.controls !== oldOptions.controls) { @@ -164,6 +170,16 @@ export class GeomapPanel extends Component { applyLayerFilter(state.handler, state.options, this.props.data); } } + + // Because data changed, check map view and change if needed (data fit) + const v = centerPointRegistry.getIfExists(this.props.options.view.id); + if (v && v.id === MapCenterID.Fit) { + const [, view] = this.initMapView(this.props.options.view); + + if (this.map && view) { + this.map.setView(view); + } + } } initMapRef = async (div: HTMLDivElement) => { @@ -173,8 +189,7 @@ export class GeomapPanel extends Component { } if (!div) { - // eslint-disable-next-line - this.map = undefined as unknown as OpenLayersMap; + this.map = undefined; return; } const { options } = this.props; @@ -187,13 +202,15 @@ export class GeomapPanel extends Component { layers.push(await initLayer(this, map, options.basemap ?? DEFAULT_BASEMAP_CONFIG, true)); // Default layer values - const layerOptions = options.layers ?? [defaultMarkersConfig]; + if (!options.layers) { + options.layers = [defaultMarkersConfig]; + } - for (const lyr of layerOptions) { + for (const lyr of options.layers) { layers.push(await initLayer(this, map, lyr, false)); } } catch (ex) { - console.error('error loading layers', ex); // eslint-disable-line no-console + console.error('error loading layers', ex); } for (const lyr of layers) { @@ -201,7 +218,7 @@ export class GeomapPanel extends Component { } this.layers = layers; this.map = map; // redundant - initViewExtent(map.getView(), options.view, map.getLayers()); + this.initViewExtent(map.getView(), options.view); this.mouseWheelZoom = new MouseWheelZoom(); this.map?.addInteraction(this.mouseWheelZoom); @@ -231,6 +248,70 @@ export class GeomapPanel extends Component { pointerMoveListener(evt, this); }; + initMapView = (config: MapViewConfig, sharedView?: View | undefined): 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; + } + } + this.initViewExtent(view, config); + + return [sharedView, view]; + }; + + initViewExtent(view: View, config: MapViewConfig) { + 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(this.layers, config.allLayers, config.lastOnly, config.layer); + if (!isEmpty(extent)) { + const padding = config.padding ?? 5; + const res = view.getResolutionForExtent(extent, this.map?.getSize()); + const maxZoom = config.zoom ?? config.maxZoom; + view.fit(extent, { + maxZoom: maxZoom, + }); + view.setResolution(res * (padding / 100 + 1)); + const adjustedZoom = view.getZoom(); + if (adjustedZoom && maxZoom && adjustedZoom > maxZoom) { + view.setZoom(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/editor/CoordinatesMapViewEditor.tsx b/public/app/plugins/panel/geomap/editor/CoordinatesMapViewEditor.tsx new file mode 100644 index 00000000000..ea83b7f72a1 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/CoordinatesMapViewEditor.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { InlineFieldRow, InlineField } from '@grafana/ui'; +import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; + +import { MapViewConfig } from '../types'; + +type Props = { + labelWidth: number; + value: MapViewConfig; + onChange: (value?: MapViewConfig | undefined) => void; +}; + +export const CoordinatesMapViewEditor = ({ labelWidth, value, onChange }: Props) => { + const onLatitudeChange = (latitude: number | undefined) => { + onChange({ ...value, lat: latitude }); + }; + + const onLongitudeChange = (longitude: number | undefined) => { + onChange({ ...value, lon: longitude }); + }; + + return ( + <> + + + + + + + + + + + + ); +}; diff --git a/public/app/plugins/panel/geomap/editor/FitMapViewEditor.tsx b/public/app/plugins/panel/geomap/editor/FitMapViewEditor.tsx new file mode 100644 index 00000000000..3c59c095ffb --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/FitMapViewEditor.tsx @@ -0,0 +1,117 @@ +import React, { useCallback, useMemo } from 'react'; + +import { SelectableValue, StandardEditorContext } from '@grafana/data'; +import { InlineFieldRow, InlineField, RadioButtonGroup, Select } from '@grafana/ui'; +import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; + +import { GeomapInstanceState, GeomapPanelOptions, MapViewConfig } from '../types'; + +type Props = { + labelWidth: number; + value: MapViewConfig; + onChange: (value?: MapViewConfig | undefined) => void; + context: StandardEditorContext; +}; + +// Data scope options for 'Fit to data' +enum DataScopeValues { + all = 'all', + layer = 'layer', + last = 'last', +} +enum DataScopeLabels { + all = 'All layers', + layer = 'Layer', + last = 'Last value', +} + +const ScopeOptions = Object.values(DataScopeValues); + +const DataScopeOptions: Array> = ScopeOptions.map((dataScopeOption) => ({ + label: DataScopeLabels[dataScopeOption], + value: dataScopeOption, +})); + +export const FitMapViewEditor = ({ labelWidth, value, onChange, context }: Props) => { + const layers = useMemo(() => { + if (context.options?.layers) { + return context.options.layers.map((layer) => ({ + label: layer.name, + value: layer.name, + description: undefined, + })); + } + return []; + }, [context.options?.layers]); + + const onSelectLayer = useCallback( + (selection: SelectableValue) => { + onChange({ ...value, layer: selection.value }); + }, + [value, onChange] + ); + + const allLayersEditorFragment = ( + + + - {value?.id === MapCenterID.Coordinates && ( - <> - - - { - onChange({ ...value, lat: v }); - }} - /> - - - - - { - onChange({ ...value, lon: v }); - }} - /> - - - + {value.id === MapCenterID.Coordinates && ( + + )} + {value.id === MapCenterID.Fit && ( + )} - + ): Extent { +import { MapLayerState } from '../types'; + +export function getLayersExtent( + layers: MapLayerState[] = [], + allLayers = false, + lastOnly = false, + layer: string | undefined +): Extent { return layers - .getArray() - .filter((l) => l instanceof VectorLayer || l instanceof LayerGroup) - .flatMap((l) => { + .filter((l) => l.layer instanceof VectorLayer || l.layer instanceof LayerGroup) + .flatMap((ll) => { + const l = ll.layer; if (l instanceof LayerGroup) { return getLayerGroupExtent(l); } else if (l instanceof VectorLayer) { - return [l.getSource().getExtent()] ?? []; + if (allLayers) { + // Return everything from all layers + return [l.getSource().getExtent()] ?? []; + } else if (lastOnly && layer === ll.options.name) { + // Return last only for selected layer + const feat = l.getSource().getFeatures(); + const featOfInterest = feat[feat.length - 1]; + const geo = featOfInterest?.getGeometry(); + if (geo) { + return [geo.getExtent()] ?? []; + } + return []; + } else if (!lastOnly && layer === ll.options.name) { + // Return all points for selected layer + return [l.getSource().getExtent()] ?? []; + } + return []; } else { return []; } diff --git a/public/app/plugins/panel/geomap/utils/utils.ts b/public/app/plugins/panel/geomap/utils/utils.ts index e9a66162676..d920369b7ad 100644 --- a/public/app/plugins/panel/geomap/utils/utils.ts +++ b/public/app/plugins/panel/geomap/utils/utils.ts @@ -10,8 +10,6 @@ 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, style: StyleConfigState, @@ -77,7 +75,7 @@ async function initGeojsonFiles() { } export const getNewOpenLayersMap = (panel: GeomapPanel, options: GeomapPanelOptions, div: HTMLDivElement) => { - const [view] = initMapView(options.view, undefined, undefined); + const [view] = panel.initMapView(options.view, undefined); return (panel.map = new OpenLayersMap({ view: view, pixelRatio: 1, // or zoom? diff --git a/public/app/plugins/panel/geomap/utils/view.ts b/public/app/plugins/panel/geomap/utils/view.ts deleted file mode 100644 index dcbdf5d14dc..00000000000 --- a/public/app/plugins/panel/geomap/utils/view.ts +++ /dev/null @@ -1,73 +0,0 @@ -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]; -}; diff --git a/public/app/plugins/panel/geomap/view.ts b/public/app/plugins/panel/geomap/view.ts index 06dc175af3f..94b9602480c 100644 --- a/public/app/plugins/panel/geomap/view.ts +++ b/public/app/plugins/panel/geomap/view.ts @@ -15,7 +15,7 @@ export enum MapCenterID { export const centerPointRegistry = new Registry(() => [ { id: MapCenterID.Fit as string, - name: 'Fit data layers', + name: 'Fit to data', zoom: 15, // max zoom }, {