From 45e176573375b99106a54fd070fcb8b425f51978 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 27 Oct 2021 14:49:11 -0700 Subject: [PATCH] Geomap: support multiple layers (#40906) --- packages/grafana-data/src/geo/layer.ts | 12 +- .../src/utils/OptionsUIBuilders.ts | 2 + .../PanelEditor/getVizualizationOptions.tsx | 10 +- .../canvas/editor/LayerElementListEditor.tsx | 4 +- .../app/plugins/panel/geomap/GeomapPanel.tsx | 313 +++++++++++++----- .../panel/geomap/editor/LayersEditor.tsx | 126 +++++++ .../panel/geomap/editor/MapViewEditor.tsx | 4 +- .../panel/geomap/editor/layerEditor.tsx | 180 +++++----- .../panel/geomap/layers/basemaps/carto.ts | 44 +-- .../panel/geomap/layers/basemaps/esri.ts | 59 ++-- .../panel/geomap/layers/basemaps/generic.ts | 37 +-- .../panel/geomap/layers/data/geojsonMapper.ts | 75 +++-- .../panel/geomap/layers/data/heatMap.tsx | 89 ++--- .../panel/geomap/layers/data/markersLayer.tsx | 119 +++---- .../geomap/layers/data/textLabelsLayer.ts | 74 ++--- public/app/plugins/panel/geomap/module.tsx | 68 ++-- public/app/plugins/panel/geomap/types.ts | 15 +- 17 files changed, 780 insertions(+), 451 deletions(-) create mode 100644 public/app/plugins/panel/geomap/editor/LayersEditor.tsx diff --git a/packages/grafana-data/src/geo/layer.ts b/packages/grafana-data/src/geo/layer.ts index c954c495d96..9993e753d23 100644 --- a/packages/grafana-data/src/geo/layer.ts +++ b/packages/grafana-data/src/geo/layer.ts @@ -66,10 +66,15 @@ export interface MapLayerOptions { /** * @alpha */ -export interface MapLayerHandler { +export interface MapLayerHandler { init: () => BaseLayer; update?: (data: PanelData) => void; legend?: ReactNode; + + /** + * Show custom elements in the panel edit UI + */ + registerOptionsUI?: (builder: PanelOptionsEditorBuilder>) => void; } /** @@ -98,9 +103,4 @@ export interface MapLayerRegistryItem extends Registr * @param options */ create: (map: Map, options: MapLayerOptions, theme: GrafanaTheme2) => Promise; - - /** - * Show custom elements in the panel edit UI - */ - registerOptionsUI?: (builder: PanelOptionsEditorBuilder>) => void; } diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts index 1317997db3c..d6c760f04ea 100644 --- a/packages/grafana-data/src/utils/OptionsUIBuilders.ts +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -16,6 +16,7 @@ import { UnitFieldConfigSettings, unitOverrideProcessor, FieldNamePickerConfigSettings, + StandardEditorContext, } from '../field'; import { PanelOptionsSupplier } from '../panel/PanelPlugin'; @@ -133,6 +134,7 @@ export class FieldConfigEditorBuilder extends OptionsUIRegistryBuilder export interface NestedValueAccess { getValue: (path: string) => any; onChange: (path: string, value: any) => void; + getContext?: (parent: StandardEditorContext) => StandardEditorContext; } export interface NestedPanelOptions { path: string; diff --git a/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx index 25904650277..f10d3104ccf 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx @@ -136,12 +136,16 @@ export function fillOptionsPaneItems( // Nested options get passed up one level if (isNestedPanelOptions(pluginOption)) { - const sub = access.getValue(pluginOption.path); + const subAccess = pluginOption.getNestedValueAccess(access); + const subContext = subAccess.getContext + ? subAccess.getContext(context) + : { ...context, options: access.getValue(pluginOption.path) }; + fillOptionsPaneItems( pluginOption.getBuilder(), - pluginOption.getNestedValueAccess(access), + subAccess, getOptionsPaneCategory, - { ...context, options: sub }, + subContext, category // parent category ); continue; diff --git a/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx b/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx index a374bdbcf33..c71025e0397 100644 --- a/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/LayerElementListEditor.tsx @@ -14,7 +14,7 @@ import appEvents from 'app/core/app_events'; type Props = StandardEditorProps; export class LayerElementListEditor extends PureComponent { - style = getStyles(config.theme); + style = getLayerDragStyles(config.theme); onAddItem = (sel: SelectableValue) => { // const reg = drawItemsRegistry.getIfExists(sel.value); @@ -162,7 +162,7 @@ export class LayerElementListEditor extends PureComponent { } } -const getStyles = stylesFactory((theme: GrafanaTheme) => ({ +export const getLayerDragStyles = stylesFactory((theme: GrafanaTheme) => ({ wrapper: css` margin-bottom: ${theme.spacing.md}; `, diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index 32c5a22cd5e..6493c67c050 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -1,16 +1,14 @@ import React, { Component, ReactNode } from 'react'; -import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry, defaultBaseLayer } from './layers/registry'; +import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from './layers/registry'; import { Map, MapBrowserEvent, 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, @@ -20,7 +18,7 @@ import { } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { ControlsOptions, GeomapPanelOptions, MapViewConfig } from './types'; +import { ControlsOptions, GeomapPanelOptions, MapLayerState, MapViewConfig } from './types'; import { centerPointRegistry, MapCenterID } from './view'; import { fromLonLat, toLonLat } from 'ol/proj'; import { Coordinate } from 'ol/coordinate'; @@ -32,12 +30,10 @@ import { getGlobalStyles } from './globalStyles'; import { Global } from '@emotion/react'; import { GeomapHoverFeature, GeomapHoverPayload } from './event'; import { DataHoverView } from './components/DataHoverView'; - -interface MapLayerState { - config: MapLayerOptions; - handler: MapLayerHandler; - layer: BaseLayer; // used to add|remove -} +import { Subscription } from 'rxjs'; +import { PanelEditExitedEvent } from 'app/types/events'; +import { defaultMarkersConfig, MARKERS_LAYER_ID } from './layers/data/markersLayer'; +import { cloneDeep } from 'lodash'; // Allows multiple panels to share the same view instance let sharedView: View | undefined = undefined; @@ -47,31 +43,51 @@ interface State extends OverlayProps { ttip?: GeomapHoverPayload; } +export interface GeomapLayerActions { + selectLayer: (uid: string) => void; + deleteLayer: (uid: string) => void; + addlayer: (type: string) => void; + reorder: (src: number, dst: number) => void; +} + +export interface GeomapInstanceState { + map?: Map; + layers: MapLayerState[]; + selected: number; + actions: GeomapLayerActions; +} + export class GeomapPanel extends Component { static contextType = PanelContextRoot; panelContext: PanelContext = {} as PanelContext; + private subs = new Subscription(); globalCSS = getGlobalStyles(config.theme2); counter = 0; - map?: Map; - basemap?: BaseLayer; - layers: MapLayerState[] = []; mouseWheelZoom?: MouseWheelZoom; style = getStyles(config.theme); hoverPayload: GeomapHoverPayload = { point: {}, pageX: -1, pageY: -1 }; readonly hoverEvent = new DataHoverEvent(this.hoverPayload); + map?: Map; + mapDiv?: HTMLDivElement; + layers: MapLayerState[] = []; + constructor(props: Props) { super(props); this.state = {}; + 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; - if (this.panelContext.onInstanceStateChange) { - this.panelContext.onInstanceStateChange(this); - } } shouldComponentUpdate(nextProps: Props) { @@ -84,25 +100,94 @@ export class GeomapPanel extends Component { this.map.updateSize(); } - // External configuration 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) { + if (this.props.data !== nextProps.data) { this.dataChanged(nextProps.data); } return true; // always? } + 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 the panel editor + if (this.panelContext.onInstanceStateChange) { + this.panelContext.onInstanceStateChange({ + map: this.map, + layers: layers, + selected, + actions: this.actions, + }); + } + } + + actions: GeomapLayerActions = { + selectLayer: (uid: string) => { + const selected = this.layers.findIndex((v) => v.UID === uid); + if (this.panelContext.onInstanceStateChange) { + this.panelContext.onInstanceStateChange({ + map: this.map, + layers: this.layers, + selected, + actions: this.actions, + }); + } + }, + deleteLayer: (uid: string) => { + const layers: MapLayerState[] = []; + for (const lyr of this.layers) { + if (lyr.UID === 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, + config: cloneDeep(item.defaultOptions), + }, + 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); + }, + }; + /** * Called when the panel options change + * + * NOTE: changes to basemap and layers are handled independently */ - optionsChanged(options: GeomapPanelOptions): boolean { - let layersChanged = false; + optionsChanged(options: GeomapPanelOptions) { const oldOptions = this.props.options; console.log('options changed!', options); @@ -115,19 +200,6 @@ export class GeomapPanel extends Component { console.log('Controls 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 ?? []); // async - layersChanged = true; - } - return layersChanged; } /** @@ -142,6 +214,7 @@ export class GeomapPanel extends Component { } initMapRef = async (div: HTMLDivElement) => { + this.mapDiv = div; if (this.map) { this.map.dispose(); } @@ -151,7 +224,8 @@ export class GeomapPanel extends Component { return; } const { options } = this.props; - this.map = new Map({ + + const map = (this.map = new Map({ view: this.initMapView(options.view), pixelRatio: 1, // or zoom? layers: [], // loaded explicitly below @@ -160,12 +234,33 @@ export class GeomapPanel extends Component { interactions: interactionDefaults({ mouseWheelZoom: false, // managed by initControls }), - }); + })); + + const layers: MapLayerState[] = []; + try { + layers.push(await this.initLayer(map, options.basemap ?? DEFAULT_BASEMAP_CONFIG, true)); + + // Default layer values + let layerOptions = options.layers; + if (!layerOptions) { + layerOptions = [defaultMarkersConfig]; + } + + for (const lyr of layerOptions) { + layers.push(await this.initLayer(map, lyr, false)); + } + } catch (ex) { + console.error('error loading layers', ex); + } + + this.layers = layers; + for (const lyr of layers) { + this.map.addLayer(lyr.layer); + } + this.mouseWheelZoom = new MouseWheelZoom(); this.map.addInteraction(this.mouseWheelZoom); this.initControls(options.controls); - this.initBasemap(options.basemap); - await this.initLayers(options.layers); this.forceUpdate(); // first render // Tooltip listener @@ -173,6 +268,22 @@ export class GeomapPanel extends Component { this.map.getViewport().addEventListener('mouseout', (evt) => { this.props.eventBus.publish(new DataHoverClearEvent()); }); + + // Notify the 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, + }); + } + }; + + clearTooltip = () => { + if (this.state.ttip) { + this.setState({ ttip: undefined }); + } }; pointerMoveListener = (evt: MapBrowserEvent) => { @@ -223,63 +334,93 @@ export class GeomapPanel extends Component { } }; - async initBasemap(cfg: MapLayerOptions) { + private updateLayer = async (uid: string, newOptions: MapLayerOptions): Promise => { if (!this.map) { - return; + return false; } + const selected = this.layers.findIndex((v) => v.UID === uid); + if (selected < 0) { + return false; + } + const layers = this.layers.slice(0); + try { + let found = false; + const current = this.layers[selected]; + const info = await this.initLayer(this.map, newOptions, current.isBasemap); + const group = this.map?.getLayers()!; + for (let i = 0; i < group?.getLength(); i++) { + if (group.item(i) === current.layer) { + found = true; + group.setAt(i, info.layer); + break; + } + } + if (!found) { + console.warn('ERROR not found', uid); + return false; + } + layers[selected] = info; - if (!cfg?.type || config.geomapDisableCustomBaseLayer) { - cfg = DEFAULT_BASEMAP_CONFIG; + // initalize with new data + if (info.handler.update) { + info.handler.update(this.props.data); + } + } catch (err) { + console.warn('ERROR', err); + return false; } - const item = geomapLayerRegistry.getIfExists(cfg.type) ?? defaultBaseLayer; - const handler = await item.create(this.map, cfg, config.theme2); - const layer = handler.init(); - if (this.basemap) { - this.map.removeLayer(this.basemap); - this.basemap.dispose(); + // TODO + // validate names, basemap etc + + this.layers = layers; + this.doOptionsUpdate(selected); + + return true; + }; + + async initLayer(map: Map, options: MapLayerOptions, isBasemap?: boolean): Promise { + if (isBasemap && (!options?.type || config.geomapDisableCustomBaseLayer)) { + options = DEFAULT_BASEMAP_CONFIG; } - this.basemap = layer; - this.map.getLayers().insertAt(0, this.basemap!); - } - async initLayers(layers: MapLayerOptions[]) { - // 1st remove existing layers - for (const state of this.layers) { - this.map!.removeLayer(state.layer); - state.layer.dispose(); + // Use default makers layer + if (!options?.type) { + options = { + type: MARKERS_LAYER_ID, + config: {}, + }; } - if (!layers) { - layers = []; + const item = geomapLayerRegistry.getIfExists(options.type); + if (!item) { + return Promise.reject('unknown layer: ' + options.type); } - const legends: React.ReactNode[] = []; - 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 = await item.create(map, options, config.theme2); + const layer = handler.init(); - const handler = await item.create(this.map!, overlay, config.theme2); - const layer = handler.init(); - (layer as any).___handler = handler; - this.map!.addLayer(layer); - this.layers.push({ - config: overlay, - layer, - handler, - }); + // const key = layer.on('change', () => { + // const state = layer.getLayerState(); + // console.log('LAYER', key, state); + // }); - if (handler.legend) { - legends.push(
{handler.legend}
); - } + if (handler.update) { + handler.update(this.props.data); } - this.setState({ bottomLeft: legends }); - // Update data after init layers - this.dataChanged(this.props.data); + const UID = `lyr-${this.counter++}`; + return { + UID, + isBasemap, + options, + layer, + handler, + + // Used by the editors + onChange: (cfg) => { + this.updateLayer(UID, cfg); + }, + }; } initMapView(config: MapViewConfig): View { @@ -367,7 +508,7 @@ export class GeomapPanel extends Component { return ( <> -
+
diff --git a/public/app/plugins/panel/geomap/editor/LayersEditor.tsx b/public/app/plugins/panel/geomap/editor/LayersEditor.tsx new file mode 100644 index 00000000000..d9fb2e2eac8 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/LayersEditor.tsx @@ -0,0 +1,126 @@ +import React, { PureComponent } from 'react'; +import { cx } from '@emotion/css'; +import { Container, Icon, IconButton, ValuePicker } from '@grafana/ui'; +import { StandardEditorProps } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'; + +import { GeomapPanelOptions } from '../types'; +import { GeomapInstanceState } from '../GeomapPanel'; +import { geomapLayerRegistry } from '../layers/registry'; +import { getLayerDragStyles } from '../../canvas/editor/LayerElementListEditor'; +import { dataLayerFilter } from './layerEditor'; + +type Props = StandardEditorProps; + +export class LayersEditor extends PureComponent { + style = getLayerDragStyles(config.theme); + + getRowStyle = (sel: boolean) => { + return sel ? `${this.style.row} ${this.style.sel}` : this.style.row; + }; + + onDragEnd = (result: DropResult) => { + if (!result.destination) { + return; + } + + const { layers, actions } = this.props.context.instanceState ?? {}; + if (!layers || !actions) { + return; + } + + // account for the reverse order and offset (0 is baselayer) + const count = layers.length - 1; + const src = (result.source.index - count) * -1; + const dst = (result.destination.index - count) * -1; + + actions.reorder(src, dst); + }; + + render() { + const { layers, selected, actions } = this.props.context.instanceState ?? {}; + if (!layers || !actions) { + return
No layers?
; + } + const baselayer = layers[0]; + + const styles = this.style; + return ( + <> + + actions.addlayer(v.value!)} + isFullWidth={true} + /> + +
+ + + + {(provided, snapshot) => ( +
+ {(() => { + // reverse order + const rows: any = []; + for (let i = layers.length - 1; i > 0; i--) { + const element = layers[i]; + rows.push( + + {(provided, snapshot) => ( +
actions!.selectLayer(element.UID)} + > + {element.options.type} +
  ({element.layer.getSourceState() ?? '?'})
+ + actions.deleteLayer(element.UID)} + surface="header" + /> + {layers.length > 2 && ( + + )} +
+ )} +
+ ); + } + return rows; + })()} + + {provided.placeholder} +
+ )} +
+
+ + {false && baselayer && ( + <> + +
+ {baselayer.options.type} +
  {baselayer.UID}
+
+ + )} + + ); + } +} diff --git a/public/app/plugins/panel/geomap/editor/MapViewEditor.tsx b/public/app/plugins/panel/geomap/editor/MapViewEditor.tsx index bfa6fb1a4cc..de640f515f4 100644 --- a/public/app/plugins/panel/geomap/editor/MapViewEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/MapViewEditor.tsx @@ -5,9 +5,9 @@ import { GeomapPanelOptions, MapViewConfig } from '../types'; import { centerPointRegistry, MapCenterID } from '../view'; import { NumberInput } from 'app/features/dimensions/editors/NumberInput'; import { toLonLat } from 'ol/proj'; -import { GeomapPanel } from '../GeomapPanel'; +import { GeomapInstanceState } from '../GeomapPanel'; -export const MapViewEditor: FC> = ({ +export const MapViewEditor: FC> = ({ value, onChange, context, diff --git a/public/app/plugins/panel/geomap/editor/layerEditor.tsx b/public/app/plugins/panel/geomap/editor/layerEditor.tsx index 8a45f521b05..44487cba9eb 100644 --- a/public/app/plugins/panel/geomap/editor/layerEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/layerEditor.tsx @@ -11,38 +11,51 @@ import { GazetteerPathEditor } from './GazetteerPathEditor'; import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders'; import { defaultMarkersConfig } from '../layers/data/markersLayer'; import { hasAlphaPanels } from 'app/core/config'; +import { MapLayerState } from '../types'; +import { get as lodashGet } from 'lodash'; +import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils'; export interface LayerEditorOptions { + state: MapLayerState; category: string[]; - path: string; basemaps: boolean; // only basemaps - current?: MapLayerOptions; } export function getLayerEditor(opts: LayerEditorOptions): NestedPanelOptions { return { category: opts.category, - path: opts.path, + path: '--', // Not used defaultValue: opts.basemaps ? DEFAULT_BASEMAP_CONFIG : defaultMarkersConfig, values: (parent: NestedValueAccess) => ({ - getValue: (path: string) => parent.getValue(`${opts.path}.${path}`), + getContext: (parent) => { + return { ...parent, options: opts.state.options, instanceState: opts.state }; + }, + getValue: (path: string) => lodashGet(opts.state.options, path), onChange: (path: string, value: any) => { + const { state } = opts; + const { options } = state; if (path === 'type' && value) { const layer = geomapLayerRegistry.getIfExists(value); if (layer) { - parent.onChange(opts.path, { - ...opts.current, // keep current shared options + console.log('Change layer type:', value, state); + state.onChange({ + ...options, // keep current shared options type: layer.id, config: { ...layer.defaultOptions }, // clone? }); - return; // reset current values + return; } } - parent.onChange(`${opts.path}.${path}`, value); + state.onChange(setOptionImmutably(options, path, value)); }, }), build: (builder, context) => { - const { options } = context; + if (!opts.state) { + console.log('MISSING LAYER!!!', opts); + return; + } + + const { handler, options } = opts.state; const layer = geomapLayerRegistry.getIfExists(options?.type); const layerTypes = geomapLayerRegistry.selectOptions( @@ -54,81 +67,88 @@ export function getLayerEditor(opts: LayerEditorOptions): NestedPanelOptions f.type === FieldType.number, - noFieldsMessage: 'No numeric fields found', - }, - showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Coords, - }) - .addFieldNamePicker({ - path: 'location.longitude', - name: 'Longitude field', - settings: { - filter: (f: Field) => f.type === FieldType.number, - noFieldsMessage: 'No numeric fields found', - }, - showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Coords, - }) - .addFieldNamePicker({ - path: 'location.geohash', - name: 'Geohash field', - settings: { - filter: (f: Field) => f.type === FieldType.string, - noFieldsMessage: 'No strings fields found', - }, - showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Geohash, - // eslint-disable-next-line react/display-name - // info: (props) =>
HELLO
, - }) - .addFieldNamePicker({ - path: 'location.lookup', - name: 'Lookup field', - settings: { - filter: (f: Field) => f.type === FieldType.string, - noFieldsMessage: 'No strings fields found', - }, - showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Lookup, - }) - .addCustomEditor({ - id: 'gazetteer', - path: 'location.gazetteer', - name: 'Gazetteer', - editor: GazetteerPathEditor, - showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Lookup, - }); - } - if (layer.registerOptionsUI) { - layer.registerOptionsUI(builder); - } - if (layer.showOpacity) { - // TODO -- add opacity check - } + if (!layer) { + return; // unknown layer type + } + + // Don't show UI for default configuration + if (options.type === DEFAULT_BASEMAP_CONFIG.type) { + return; + } + + if (layer.showLocation) { + builder + .addRadio({ + path: 'location.mode', + name: 'Location', + description: '', + defaultValue: FrameGeometrySourceMode.Auto, + settings: { + options: [ + { value: FrameGeometrySourceMode.Auto, label: 'Auto' }, + { value: FrameGeometrySourceMode.Coords, label: 'Coords' }, + { value: FrameGeometrySourceMode.Geohash, label: 'Geohash' }, + { value: FrameGeometrySourceMode.Lookup, label: 'Lookup' }, + ], + }, + }) + .addFieldNamePicker({ + path: 'location.latitude', + name: 'Latitude field', + settings: { + filter: (f: Field) => f.type === FieldType.number, + noFieldsMessage: 'No numeric fields found', + }, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Coords, + }) + .addFieldNamePicker({ + path: 'location.longitude', + name: 'Longitude field', + settings: { + filter: (f: Field) => f.type === FieldType.number, + noFieldsMessage: 'No numeric fields found', + }, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Coords, + }) + .addFieldNamePicker({ + path: 'location.geohash', + name: 'Geohash field', + settings: { + filter: (f: Field) => f.type === FieldType.string, + noFieldsMessage: 'No strings fields found', + }, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Geohash, + // eslint-disable-next-line react/display-name + // info: (props) =>
HELLO
, + }) + .addFieldNamePicker({ + path: 'location.lookup', + name: 'Lookup field', + settings: { + filter: (f: Field) => f.type === FieldType.string, + noFieldsMessage: 'No strings fields found', + }, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Lookup, + }) + .addCustomEditor({ + id: 'gazetteer', + path: 'location.gazetteer', + name: 'Gazetteer', + editor: GazetteerPathEditor, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Lookup, + }); + } + if (handler.registerOptionsUI) { + handler.registerOptionsUI(builder); + } + if (layer.showOpacity) { + // TODO -- add opacity check } }, }; @@ -144,7 +164,7 @@ function baseMapFilter(layer: MapLayerRegistryItem): boolean { return true; } -function dataLayerFilter(layer: MapLayerRegistryItem): boolean { +export function dataLayerFilter(layer: MapLayerRegistryItem): boolean { if (layer.isBaseMap) { return false; } diff --git a/public/app/plugins/panel/geomap/layers/basemaps/carto.ts b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts index 4c1a35dc940..ed903deb7d4 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/carto.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts @@ -50,29 +50,29 @@ export const carto: MapLayerRegistryItem = { }), }); }, - }), - registerOptionsUI: (builder) => { - builder - .addRadio({ - path: 'config.theme', - name: 'Theme', - settings: { - options: [ - { value: LayerTheme.Auto, label: 'Auto', description: 'Match grafana theme' }, - { value: LayerTheme.Light, label: 'Light' }, - { value: LayerTheme.Dark, label: 'Dark' }, - ], - }, - defaultValue: defaultCartoConfig.theme!, - }) - .addBooleanSwitch({ - path: 'config.showLabels', - name: 'Show labels', - description: '', - defaultValue: defaultCartoConfig.showLabels, - }); - }, + registerOptionsUI: (builder) => { + builder + .addRadio({ + path: 'config.theme', + name: 'Theme', + settings: { + options: [ + { value: LayerTheme.Auto, label: 'Auto', description: 'Match grafana theme' }, + { value: LayerTheme.Light, label: 'Light' }, + { value: LayerTheme.Dark, label: 'Dark' }, + ], + }, + defaultValue: defaultCartoConfig.theme!, + }) + .addBooleanSwitch({ + path: 'config.showLabels', + name: 'Show labels', + description: '', + defaultValue: defaultCartoConfig.showLabels, + }); + }, + }), }; export const cartoLayers = [carto]; diff --git a/public/app/plugins/panel/geomap/layers/basemaps/esri.ts b/public/app/plugins/panel/geomap/layers/basemaps/esri.ts index 6d2cd519c62..06ebaadd42b 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/esri.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/esri.ts @@ -66,35 +66,36 @@ export const esriXYZTiles: MapLayerRegistryItem = { cfg.attribution = `Tiles © ArcGIS`; } const opts = { ...options, config: cfg as XYZConfig }; - return xyzTiles.create(map, opts, theme); - }, - - registerOptionsUI: (builder) => { - builder - .addSelect({ - path: 'config.server', - name: 'Server instance', - settings: { - options: publicServiceRegistry.selectOptions().options, - }, - }) - .addTextInput({ - path: 'config.url', - name: 'URL template', - description: 'Must include {x}, {y} or {-y}, and {z} placeholders', - settings: { - placeholder: defaultXYZConfig.url, - }, - showIf: (cfg) => cfg.config?.server === CUSTOM_SERVICE, - }) - .addTextInput({ - path: 'config.attribution', - name: 'Attribution', - settings: { - placeholder: defaultXYZConfig.attribution, - }, - showIf: (cfg) => cfg.config?.server === CUSTOM_SERVICE, - }); + return xyzTiles.create(map, opts, theme).then((xyz) => { + xyz.registerOptionsUI = (builder) => { + builder + .addSelect({ + path: 'config.server', + name: 'Server instance', + settings: { + options: publicServiceRegistry.selectOptions().options, + }, + }) + .addTextInput({ + path: 'config.url', + name: 'URL template', + description: 'Must include {x}, {y} or {-y}, and {z} placeholders', + settings: { + placeholder: defaultXYZConfig.url, + }, + showIf: (cfg) => cfg.config?.server === CUSTOM_SERVICE, + }) + .addTextInput({ + path: 'config.attribution', + name: 'Attribution', + settings: { + placeholder: defaultXYZConfig.attribution, + }, + showIf: (cfg) => cfg.config?.server === CUSTOM_SERVICE, + }); + }; + return xyz; + }); }, defaultOptions: { diff --git a/public/app/plugins/panel/geomap/layers/basemaps/generic.ts b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts index 00677d88b65..8bb50454719 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/generic.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts @@ -37,26 +37,25 @@ export const xyzTiles: MapLayerRegistryItem = { maxZoom: cfg.maxZoom, }); }, + registerOptionsUI: (builder) => { + builder + .addTextInput({ + path: 'config.url', + name: 'URL template', + description: 'Must include {x}, {y} or {-y}, and {z} placeholders', + settings: { + placeholder: defaultXYZConfig.url, + }, + }) + .addTextInput({ + path: 'config.attribution', + name: 'Attribution', + settings: { + placeholder: defaultXYZConfig.attribution, + }, + }); + }, }), - - registerOptionsUI: (builder) => { - builder - .addTextInput({ - path: 'config.url', - name: 'URL template', - description: 'Must include {x}, {y} or {-y}, and {z} placeholders', - settings: { - placeholder: defaultXYZConfig.url, - }, - }) - .addTextInput({ - path: 'config.attribution', - name: 'Attribution', - settings: { - placeholder: defaultXYZConfig.attribution, - }, - }); - }, }; export const genericLayers = [xyzTiles]; diff --git a/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts b/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts index f5507acf912..c94196f2f32 100644 --- a/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts +++ b/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts @@ -3,6 +3,7 @@ import Map from 'ol/Map'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import GeoJSON from 'ol/format/GeoJSON'; +import { unByKey } from 'ol/Observable'; import { Feature } from 'ol'; import { Geometry } from 'ol/geom'; import { getGeoMapStyle } from '../../utils/getGeoMapStyle'; @@ -53,6 +54,19 @@ export const geojsonMapper: MapLayerRegistryItem = { format: new GeoJSON(), }); + const key = source.on('change', () => { + if (source.getState() == 'ready') { + unByKey(key); + // var olFeatures = source.getFeatures(); // olFeatures.length === 1 + // window.setTimeout(function () { + // var olFeatures = source.getFeatures(); // olFeatures.length > 1 + // // Only after using setTimeout can I search the feature list... :( + // }, 100) + + console.log('SOURCE READY!!!', source.getFeatures().length); + } + }); + const defaultStyle = new Style({ stroke: new Stroke({ color: DEFAULT_STYLE_RULE.fillColor, @@ -80,37 +94,40 @@ export const geojsonMapper: MapLayerRegistryItem = { update: (data: PanelData) => { console.log('todo... find values matching the ID and update'); - // Update each feature - source.getFeatures().forEach((f) => { - console.log('Find: ', f.getId(), f.getProperties()); - }); + // // Update each feature + // source.getFeatures().forEach((f) => { + // console.log('Find: ', f.getId(), f.getProperties()); + // }); }, - }; - }, - // Geojson source url - registerOptionsUI: (builder) => { - builder - .addSelect({ - path: 'config.src', - name: 'GeoJSON URL', - settings: { - options: [ - { label: 'public/maps/countries.geojson', value: 'public/maps/countries.geojson' }, - { label: 'public/maps/usa-states.geojson', value: 'public/maps/usa-states.geojson' }, - ], - allowCustomValue: true, - }, - defaultValue: defaultOptions.src, - }) - .addCustomEditor({ - id: 'config.styles', - path: 'config.styles', - name: 'Style Rules', - editor: GeomapStyleRulesEditor, - settings: {}, - defaultValue: [], - }); + // Geojson source url + registerOptionsUI: (builder) => { + const features = source.getFeatures(); + console.log('FEATURES', source.getState(), features.length, options); + + builder + .addSelect({ + path: 'config.src', + name: 'GeoJSON URL', + settings: { + options: [ + { label: 'public/maps/countries.geojson', value: 'public/maps/countries.geojson' }, + { label: 'public/maps/usa-states.geojson', value: 'public/maps/usa-states.geojson' }, + ], + allowCustomValue: true, + }, + defaultValue: defaultOptions.src, + }) + .addCustomEditor({ + id: 'config.styles', + path: 'config.styles', + name: 'Style Rules', + editor: GeomapStyleRulesEditor, + settings: {}, + defaultValue: [], + }); + }, + }; }, defaultOptions, }; diff --git a/public/app/plugins/panel/geomap/layers/data/heatMap.tsx b/public/app/plugins/panel/geomap/layers/data/heatMap.tsx index 85672d82df7..497eedc682d 100644 --- a/public/app/plugins/panel/geomap/layers/data/heatMap.tsx +++ b/public/app/plugins/panel/geomap/layers/data/heatMap.tsx @@ -110,52 +110,53 @@ export const heatmapLayer: MapLayerRegistryItem = { } vectorLayer.setGradient(colors); }, + + // Heatmap overlay options + registerOptionsUI: (builder) => { + builder + .addCustomEditor({ + id: 'config.weight', + path: 'config.weight', + name: 'Weight values', + description: 'Scale the distribution for each row', + editor: ScaleDimensionEditor, + settings: { + min: 0, // no contribution + max: 1, + hideRange: true, // Don't show the scale factor + }, + defaultValue: { + // Configured values + fixed: 1, + min: 0, + max: 1, + }, + }) + .addSliderInput({ + path: 'config.radius', + description: 'configures the size of clusters', + name: 'Radius', + defaultValue: defaultOptions.radius, + settings: { + min: 1, + max: 50, + step: 1, + }, + }) + .addSliderInput({ + path: 'config.blur', + description: 'configures the amount of blur of clusters', + name: 'Blur', + defaultValue: defaultOptions.blur, + settings: { + min: 1, + max: 50, + step: 1, + }, + }); + }, }; }, - // Heatmap overlay options - registerOptionsUI: (builder) => { - builder - .addCustomEditor({ - id: 'config.weight', - path: 'config.weight', - name: 'Weight values', - description: 'Scale the distribution for each row', - editor: ScaleDimensionEditor, - settings: { - min: 0, // no contribution - max: 1, - hideRange: true, // Don't show the scale factor - }, - defaultValue: { - // Configured values - fixed: 1, - min: 0, - max: 1, - }, - }) - .addSliderInput({ - path: 'config.radius', - description: 'configures the size of clusters', - name: 'Radius', - defaultValue: defaultOptions.radius, - settings: { - min: 1, - max: 50, - step: 1, - }, - }) - .addSliderInput({ - path: 'config.blur', - description: 'configures the amount of blur of clusters', - name: 'Blur', - defaultValue: defaultOptions.blur, - settings: { - min: 1, - max: 50, - step: 1, - }, - }); - }, // fill in the default values defaultOptions, }; diff --git a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx index 0da8ff64d9d..7d59a3306c1 100644 --- a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx @@ -165,67 +165,68 @@ export const markersLayer: MapLayerRegistryItem = { const vectorSource = new source.Vector({ features }); vectorLayer.setSource(vectorSource); }, + + // Marker overlay options + registerOptionsUI: (builder) => { + builder + .addCustomEditor({ + id: 'config.size', + path: 'config.size', + name: 'Marker Size', + editor: ScaleDimensionEditor, + settings: { + min: 1, + max: 100, // possible in the UI + }, + defaultValue: { + // Configured values + fixed: DEFAULT_SIZE, + min: 1, + max: 20, + }, + }) + .addCustomEditor({ + id: 'config.markerSymbol', + path: 'config.markerSymbol', + name: 'Marker Symbol', + editor: ResourceDimensionEditor, + defaultValue: defaultOptions.markerSymbol, + settings: { + resourceType: 'icon', + showSourceRadio: false, + folderName: ResourceFolderName.Marker, + }, + }) + .addCustomEditor({ + id: 'config.color', + path: 'config.color', + name: 'Marker Color', + editor: ColorDimensionEditor, + settings: {}, + defaultValue: { + // Configured values + fixed: 'grey', + }, + }) + .addSliderInput({ + path: 'config.fillOpacity', + name: 'Fill opacity', + defaultValue: defaultOptions.fillOpacity, + settings: { + min: 0, + max: 1, + step: 0.1, + }, + }) + .addBooleanSwitch({ + path: 'config.showLegend', + name: 'Show legend', + description: 'Show legend', + defaultValue: defaultOptions.showLegend, + }); + }, }; }, - // Marker overlay options - registerOptionsUI: (builder) => { - builder - .addCustomEditor({ - id: 'config.size', - path: 'config.size', - name: 'Marker Size', - editor: ScaleDimensionEditor, - settings: { - min: 1, - max: 100, // possible in the UI - }, - defaultValue: { - // Configured values - fixed: DEFAULT_SIZE, - min: 1, - max: 20, - }, - }) - .addCustomEditor({ - id: 'config.markerSymbol', - path: 'config.markerSymbol', - name: 'Marker Symbol', - editor: ResourceDimensionEditor, - defaultValue: defaultOptions.markerSymbol, - settings: { - resourceType: 'icon', - showSourceRadio: false, - folderName: ResourceFolderName.Marker, - }, - }) - .addCustomEditor({ - id: 'config.color', - path: 'config.color', - name: 'Marker Color', - editor: ColorDimensionEditor, - settings: {}, - defaultValue: { - // Configured values - fixed: 'grey', - }, - }) - .addSliderInput({ - path: 'config.fillOpacity', - name: 'Fill opacity', - defaultValue: defaultOptions.fillOpacity, - settings: { - min: 0, - max: 1, - step: 0.1, - }, - }) - .addBooleanSwitch({ - path: 'config.showLegend', - name: 'Show legend', - description: 'Show legend', - defaultValue: defaultOptions.showLegend, - }); - }, // fill in the default values defaultOptions, diff --git a/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts b/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts index cf0cbd0e567..0f81452e757 100644 --- a/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts +++ b/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts @@ -119,44 +119,44 @@ export const textLabelsLayer: MapLayerRegistryItem = { const vectorSource = new source.Vector({ features }); vectorLayer.setSource(vectorSource); }, + registerOptionsUI: (builder) => { + builder + .addCustomEditor({ + id: 'config.labelText', + name: 'Text label', + path: 'config.labelText', + editor: TextDimensionEditor, + }) + .addCustomEditor({ + id: 'config.color', + path: 'config.color', + name: 'Text color', + editor: ColorDimensionEditor, + settings: {}, + }) + .addSliderInput({ + path: 'config.fillOpacity', + name: 'Text opacity', + defaultValue: defaultOptions.fillOpacity, + settings: { + min: 0, + max: 1, + step: 0.1, + }, + }) + .addCustomEditor({ + id: 'config.fontSize', + path: 'config.fontSize', + name: 'Text size', + editor: ScaleDimensionEditor, + settings: { + fixed: defaultOptions.fontSize.fixed, + min: defaultOptions.fontSize.min, + max: defaultOptions.fontSize.max, + }, + }); + }, }; }, - registerOptionsUI: (builder) => { - builder - .addCustomEditor({ - id: 'config.labelText', - name: 'Text label', - path: 'config.labelText', - editor: TextDimensionEditor, - }) - .addCustomEditor({ - id: 'config.color', - path: 'config.color', - name: 'Text color', - editor: ColorDimensionEditor, - settings: {}, - }) - .addSliderInput({ - path: 'config.fillOpacity', - name: 'Text opacity', - defaultValue: defaultOptions.fillOpacity, - settings: { - min: 0, - max: 1, - step: 0.1, - }, - }) - .addCustomEditor({ - id: 'config.fontSize', - path: 'config.fontSize', - name: 'Text size', - editor: ScaleDimensionEditor, - settings: { - fixed: defaultOptions.fontSize.fixed, - min: defaultOptions.fontSize.min, - max: defaultOptions.fontSize.max, - }, - }); - }, defaultOptions, }; diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx index 90ee0aefd55..45c7892d737 100644 --- a/public/app/plugins/panel/geomap/module.tsx +++ b/public/app/plugins/panel/geomap/module.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { PanelPlugin } from '@grafana/data'; -import { GeomapPanel } from './GeomapPanel'; +import { GeomapInstanceState, GeomapPanel } from './GeomapPanel'; import { MapViewEditor } from './editor/MapViewEditor'; import { defaultView, GeomapPanelOptions } from './types'; import { mapPanelChangedHandler, mapMigrationHandler } from './migrations'; import { getLayerEditor } from './editor/layerEditor'; +import { LayersEditor } from './editor/LayersEditor'; import { config } from '@grafana/runtime'; export const plugin = new PanelPlugin(GeomapPanel) @@ -32,45 +33,48 @@ export const plugin = new PanelPlugin(GeomapPanel) defaultValue: defaultView.shared, }); - // Check server settings to disable custom basemap settings - if (config.geomapDisableCustomBaseLayer) { + const state = context.instanceState as GeomapInstanceState; + if (!state?.layers) { + // TODO? show spinner? + } else { builder.addCustomEditor({ - category: ['Base layer'], + category: ['Data layer'], id: 'layers', path: '', name: '', - // eslint-disable-next-line react/display-name - editor: () =>
The base layer is configured by the server admin.
, + editor: LayersEditor, }); - } else { - builder.addNestedOptions( - getLayerEditor({ - category: ['Base layer'], - path: 'basemap', // only one for now - basemaps: true, - current: context.options?.layers?.[0], - }) - ); - } - let layerCount = context.options?.layers?.length; - if (layerCount == null || layerCount < 1) { - layerCount = 1; - } + const selected = state.layers[state.selected]; + if (state.selected && selected) { + builder.addNestedOptions( + getLayerEditor({ + state: selected, + category: ['Data layer'], + basemaps: false, + }) + ); + } - for (let i = 0; i < layerCount; i++) { - let name = 'Data layer'; - if (i > 0) { - name += ` (${i + 1})`; + const baselayer = state.layers[0]; + if (config.geomapDisableCustomBaseLayer) { + builder.addCustomEditor({ + category: ['Base layer'], + id: 'layers', + path: '', + name: '', + // eslint-disable-next-line react/display-name + editor: () =>
The base layer is configured by the server admin.
, + }); + } else if (baselayer) { + builder.addNestedOptions( + getLayerEditor({ + state: baselayer, + category: ['Base layer'], + basemaps: true, + }) + ); } - builder.addNestedOptions( - getLayerEditor({ - category: [name], - path: `layers[${i}]`, // only one for now - basemaps: false, - current: context.options?.layers?.[i], - }) - ); } // The controls section diff --git a/public/app/plugins/panel/geomap/types.ts b/public/app/plugins/panel/geomap/types.ts index ad1f37b7067..1f7309b178b 100644 --- a/public/app/plugins/panel/geomap/types.ts +++ b/public/app/plugins/panel/geomap/types.ts @@ -1,4 +1,5 @@ -import { MapLayerOptions } from '@grafana/data'; +import { MapLayerHandler, MapLayerOptions } from '@grafana/data'; +import BaseLayer from 'ol/layer/Base'; import { Units } from 'ol/proj/Units'; import { Style } from 'ol/style'; import { MapCenterID } from './view'; @@ -62,6 +63,18 @@ export enum ComparisonOperation { GT = 'gt', GTE = 'gte', } + +//------------------- +// Runtime model +//------------------- +export interface MapLayerState { + UID: string; // value changes with each initalization + options: MapLayerOptions; + handler: MapLayerHandler; + layer: BaseLayer; // the openlayers instance + onChange: (cfg: MapLayerOptions) => void; + isBasemap?: boolean; +} export interface StyleMakerConfig { color: string; fillColor: string;