Geomap: Add dynamic initial view options (#54419)

* Geomap: Add dynamic initial view options

* Add control options for dynamic initial view

* Add handling for last only scope

* Remove stale todos

* Only reinitialize map view during data if fit

* Add options for data fit

In map init, remove dependency on layers input.

Add a boolean to map view config to handle all layers, with default
set to true. Also, add layer string to handle currently selected layer.

Change some verbage.

In map view editor, add controls for data extent options. Only display
layer selection when all layers is not selected.

Update layer extent function to handle all options permutations. When
all layers is selected, return all data for extent calculation. When
last only is selected, only return last data point in matching layer for
extent calculation. Otherwise, return all data points in matching layer
for extent calculation.

* Change padding tooltip and hide for last only

* Simplify getLayersExtent call

* Add enums for data scope options

* Update GeomapPanel for refactor merge

* Move view init functions back into geomap class

Re-apply data change handling and extent calculation handling.

* Update padding tooltip verbage

* Ensure geomap panel options layers are initialized

* Clean up GeomapPanel file (betterer)

* refactors / clean up

* more cleanup

Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Drew Slobodnjak 2022-09-21 13:11:44 -07:00 committed by GitHub
parent 5be04f5336
commit 6e85dfa25a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 296 additions and 129 deletions

View File

@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
}
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<Props, State> {
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<Props, State> {
}
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<Props, State> {
pointerMoveListener(evt, this);
};
initMapView = (config: MapViewConfig, sharedView?: View | undefined): Array<View | undefined> => {
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;

View File

@ -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 (
<>
<InlineFieldRow>
<InlineField label="Latitude" labelWidth={labelWidth} grow={true}>
<NumberInput value={value.lat} min={-90} max={90} step={0.001} onChange={onLatitudeChange} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Longitude" labelWidth={labelWidth} grow={true}>
<NumberInput value={value.lon} min={-180} max={180} step={0.001} onChange={onLongitudeChange} />
</InlineField>
</InlineFieldRow>
</>
);
};

View File

@ -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<GeomapPanelOptions, GeomapInstanceState>;
};
// 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<SelectableValue<DataScopeValues>> = 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<string>) => {
onChange({ ...value, layer: selection.value });
},
[value, onChange]
);
const allLayersEditorFragment = (
<InlineFieldRow>
<InlineField label="Layer" labelWidth={labelWidth} grow={true}>
<Select options={layers} onChange={onSelectLayer} placeholder={layers[0]?.label} />
</InlineField>
</InlineFieldRow>
);
const onChangePadding = (padding: number | undefined) => {
onChange({ ...value, padding: padding });
};
const lastOnlyEditorFragment = (
<InlineFieldRow>
<InlineField
label="Padding"
labelWidth={labelWidth}
grow={true}
tooltip="sets padding in relative percent beyond data extent"
>
<NumberInput value={value?.padding ?? 5} min={0} step={1} onChange={onChangePadding} />
</InlineField>
</InlineFieldRow>
);
const currentDataScope = value.allLayers
? DataScopeValues.all
: !value.allLayers && value.lastOnly
? DataScopeValues.last
: DataScopeValues.layer;
const onDataScopeChange = (dataScope: DataScopeValues) => {
if (dataScope !== DataScopeValues.all && !value.layer) {
onChange({
...value,
allLayers: dataScope === String(DataScopeValues.all),
lastOnly: dataScope === String(DataScopeValues.last),
layer: layers[0].value,
});
} else {
onChange({
...value,
allLayers: dataScope === String(DataScopeValues.all),
lastOnly: dataScope === String(DataScopeValues.last),
});
}
};
return (
<>
<InlineFieldRow>
<InlineField label="Data" labelWidth={labelWidth} grow={true}>
<RadioButtonGroup
value={currentDataScope}
options={DataScopeOptions}
onChange={onDataScopeChange}
></RadioButtonGroup>
</InlineField>
</InlineFieldRow>
{!value?.allLayers && allLayersEditorFragment}
{!value?.lastOnly && lastOnlyEditorFragment}
</>
);
};

View File

@ -1,5 +1,5 @@
import { toLonLat } from 'ol/proj';
import React, { FC, useMemo, useCallback } from 'react';
import React, { useMemo, useCallback } from 'react';
import { StandardEditorProps, SelectableValue } from '@grafana/data';
import { Button, InlineField, InlineFieldRow, Select, VerticalGroup } from '@grafana/ui';
@ -8,9 +8,14 @@ import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
import { GeomapPanelOptions, MapViewConfig, GeomapInstanceState } from '../types';
import { centerPointRegistry, MapCenterID } from '../view';
export const MapViewEditor: FC<
StandardEditorProps<MapViewConfig, unknown, GeomapPanelOptions, GeomapInstanceState>
> = ({ value, onChange, context }) => {
import { CoordinatesMapViewEditor } from './CoordinatesMapViewEditor';
import { FitMapViewEditor } from './FitMapViewEditor';
export const MapViewEditor = ({
value,
onChange,
context,
}: StandardEditorProps<MapViewConfig, unknown, GeomapPanelOptions, GeomapInstanceState>) => {
const labelWidth = 10;
const views = useMemo(() => {
@ -64,39 +69,15 @@ export const MapViewEditor: FC<
<Select options={views.options} value={views.current} onChange={onSelectView} />
</InlineField>
</InlineFieldRow>
{value?.id === MapCenterID.Coordinates && (
<>
<InlineFieldRow>
<InlineField label="Latitude" labelWidth={labelWidth} grow={true}>
<NumberInput
value={value.lat}
min={-90}
max={90}
step={0.001}
onChange={(v) => {
onChange({ ...value, lat: v });
}}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Longitude" labelWidth={labelWidth} grow={true}>
<NumberInput
value={value.lon}
min={-180}
max={180}
step={0.001}
onChange={(v) => {
onChange({ ...value, lon: v });
}}
/>
</InlineField>
</InlineFieldRow>
</>
{value.id === MapCenterID.Coordinates && (
<CoordinatesMapViewEditor labelWidth={labelWidth} value={value} onChange={onChange} />
)}
{value.id === MapCenterID.Fit && (
<FitMapViewEditor labelWidth={labelWidth} value={value} onChange={onChange} context={context} />
)}
<InlineFieldRow>
<InlineField label="Zoom" labelWidth={labelWidth} grow={true}>
<InlineField label={value?.id === MapCenterID.Fit ? 'Max Zoom' : 'Zoom'} labelWidth={labelWidth} grow={true}>
<NumberInput
value={value?.zoom ?? 1}
min={1}

View File

@ -48,6 +48,10 @@ export interface MapViewConfig {
zoom?: number;
minZoom?: number;
maxZoom?: number;
padding?: number;
allLayers?: boolean;
lastOnly?: boolean;
layer?: string;
shared?: boolean;
}
@ -56,6 +60,7 @@ export const defaultView: MapViewConfig = {
lat: 0,
lon: 0,
zoom: 1,
allLayers: true,
};
/** Support hide from legend/tooltip */

View File

@ -1,18 +1,39 @@
import { Collection } from 'ol';
import { createEmpty, extend, Extent } from 'ol/extent';
import BaseLayer from 'ol/layer/Base';
import LayerGroup from 'ol/layer/Group';
import VectorLayer from 'ol/layer/Vector';
export function getLayersExtent(layers: Collection<BaseLayer>): 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 [];
}

View File

@ -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?

View File

@ -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<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) {
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<BaseLayer>
): Array<View | undefined> => {
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];
};

View File

@ -15,7 +15,7 @@ export enum MapCenterID {
export const centerPointRegistry = new Registry<MapCenterItems>(() => [
{
id: MapCenterID.Fit as string,
name: 'Fit data layers',
name: 'Fit to data',
zoom: 15, // max zoom
},
{