Geomap: support multiple layers (#40906)

This commit is contained in:
Ryan McKinley 2021-10-27 14:49:11 -07:00 committed by GitHub
parent 14225b07b2
commit 45e1765733
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 786 additions and 457 deletions

View File

@ -66,10 +66,15 @@ export interface MapLayerOptions<TConfig = any> {
/**
* @alpha
*/
export interface MapLayerHandler {
export interface MapLayerHandler<TConfig = any> {
init: () => BaseLayer;
update?: (data: PanelData) => void;
legend?: ReactNode;
/**
* Show custom elements in the panel edit UI
*/
registerOptionsUI?: (builder: PanelOptionsEditorBuilder<MapLayerOptions<TConfig>>) => void;
}
/**
@ -98,9 +103,4 @@ export interface MapLayerRegistryItem<TConfig = MapLayerOptions> extends Registr
* @param options
*/
create: (map: Map, options: MapLayerOptions<TConfig>, theme: GrafanaTheme2) => Promise<MapLayerHandler>;
/**
* Show custom elements in the panel edit UI
*/
registerOptionsUI?: (builder: PanelOptionsEditorBuilder<MapLayerOptions<TConfig>>) => void;
}

View File

@ -16,6 +16,7 @@ import {
UnitFieldConfigSettings,
unitOverrideProcessor,
FieldNamePickerConfigSettings,
StandardEditorContext,
} from '../field';
import { PanelOptionsSupplier } from '../panel/PanelPlugin';
@ -133,6 +134,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
export interface NestedValueAccess {
getValue: (path: string) => any;
onChange: (path: string, value: any) => void;
getContext?: (parent: StandardEditorContext<any, any>) => StandardEditorContext<any, any>;
}
export interface NestedPanelOptions<TSub = any> {
path: string;

View File

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

View File

@ -14,7 +14,7 @@ import appEvents from 'app/core/app_events';
type Props = StandardEditorProps<any, InstanceState, PanelOptions>;
export class LayerElementListEditor extends PureComponent<Props> {
style = getStyles(config.theme);
style = getLayerDragStyles(config.theme);
onAddItem = (sel: SelectableValue<string>) => {
// const reg = drawItemsRegistry.getIfExists(sel.value);
@ -162,7 +162,7 @@ export class LayerElementListEditor extends PureComponent<Props> {
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
export const getLayerDragStyles = stylesFactory((theme: GrafanaTheme) => ({
wrapper: css`
margin-bottom: ${theme.spacing.md};
`,

View File

@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
}
initMapRef = async (div: HTMLDivElement) => {
this.mapDiv = div;
if (this.map) {
this.map.dispose();
}
@ -151,7 +224,8 @@ export class GeomapPanel extends Component<Props, State> {
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<Props, State> {
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<Props, State> {
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<UIEvent>) => {
@ -223,63 +334,93 @@ export class GeomapPanel extends Component<Props, State> {
}
};
async initBasemap(cfg: MapLayerOptions) {
private updateLayer = async (uid: string, newOptions: MapLayerOptions): Promise<boolean> => {
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;
// initalize with new data
if (info.handler.update) {
info.handler.update(this.props.data);
}
} catch (err) {
console.warn('ERROR', err);
return false;
}
// TODO
// validate names, basemap etc
this.layers = layers;
this.doOptionsUpdate(selected);
return true;
};
async initLayer(map: Map, options: MapLayerOptions, isBasemap?: boolean): Promise<MapLayerState> {
if (isBasemap && (!options?.type || config.geomapDisableCustomBaseLayer)) {
options = DEFAULT_BASEMAP_CONFIG;
}
if (!cfg?.type || config.geomapDisableCustomBaseLayer) {
cfg = DEFAULT_BASEMAP_CONFIG;
// Use default makers layer
if (!options?.type) {
options = {
type: MARKERS_LAYER_ID,
config: {},
};
}
const item = geomapLayerRegistry.getIfExists(cfg.type) ?? defaultBaseLayer;
const handler = await item.create(this.map, cfg, config.theme2);
const item = geomapLayerRegistry.getIfExists(options.type);
if (!item) {
return Promise.reject('unknown layer: ' + options.type);
}
const handler = await item.create(map, options, config.theme2);
const layer = handler.init();
if (this.basemap) {
this.map.removeLayer(this.basemap);
this.basemap.dispose();
}
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();
// const key = layer.on('change', () => {
// const state = layer.getLayerState();
// console.log('LAYER', key, state);
// });
if (handler.update) {
handler.update(this.props.data);
}
if (!layers) {
layers = [];
}
const UID = `lyr-${this.counter++}`;
return {
UID,
isBasemap,
options,
layer,
handler,
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(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,
});
if (handler.legend) {
legends.push(<div key={`${this.counter++}`}>{handler.legend}</div>);
}
}
this.setState({ bottomLeft: legends });
// Update data after init layers
this.dataChanged(this.props.data);
// Used by the editors
onChange: (cfg) => {
this.updateLayer(UID, cfg);
},
};
}
initMapView(config: MapViewConfig): View {
@ -367,7 +508,7 @@ export class GeomapPanel extends Component<Props, State> {
return (
<>
<Global styles={this.globalCSS} />
<div className={this.style.wrap}>
<div className={this.style.wrap} onMouseLeave={this.clearTooltip}>
<div className={this.style.map} ref={this.initMapRef}></div>
<GeomapOverlay bottomLeft={bottomLeft} topRight={topRight} />
</div>

View File

@ -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<any, any, GeomapPanelOptions, GeomapInstanceState>;
export class LayersEditor extends PureComponent<Props> {
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 <div>No layers?</div>;
}
const baselayer = layers[0];
const styles = this.style;
return (
<>
<Container>
<ValuePicker
icon="plus"
label="Add layer"
variant="secondary"
options={geomapLayerRegistry.selectOptions(undefined, dataLayerFilter).options}
onChange={(v) => actions.addlayer(v.value!)}
isFullWidth={true}
/>
</Container>
<br />
<DragDropContext onDragEnd={this.onDragEnd}>
<Droppable droppableId="droppable">
{(provided, snapshot) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{(() => {
// reverse order
const rows: any = [];
for (let i = layers.length - 1; i > 0; i--) {
const element = layers[i];
rows.push(
<Draggable key={element.UID} draggableId={element.UID} index={rows.length}>
{(provided, snapshot) => (
<div
className={this.getRowStyle(i === selected)}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onMouseDown={() => actions!.selectLayer(element.UID)}
>
<span className={styles.typeWrapper}>{element.options.type}</span>
<div className={styles.textWrapper}>&nbsp; ({element.layer.getSourceState() ?? '?'})</div>
<IconButton
name="trash-alt"
title={'remove'}
className={cx(styles.actionIcon, styles.dragIcon)}
onClick={() => actions.deleteLayer(element.UID)}
surface="header"
/>
{layers.length > 2 && (
<Icon
title="Drag and drop to reorder"
name="draggabledots"
size="lg"
className={styles.dragIcon}
/>
)}
</div>
)}
</Draggable>
);
}
return rows;
})()}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{false && baselayer && (
<>
<label>Base layer</label>
<div className={this.getRowStyle(false)}>
<span className={styles.typeWrapper}>{baselayer.options.type}</span>
<div className={styles.textWrapper}>&nbsp; {baselayer.UID}</div>
</div>
</>
)}
</>
);
}
}

View File

@ -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<StandardEditorProps<MapViewConfig, any, GeomapPanelOptions, GeomapPanel>> = ({
export const MapViewEditor: FC<StandardEditorProps<MapViewConfig, any, GeomapPanelOptions, GeomapInstanceState>> = ({
value,
onChange,
context,

View File

@ -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<MapLayerOptions> {
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<Map
builder.addSelect({
path: 'type',
name: undefined as any, // required, but hide space
name: 'Layer type', // required, but hide space
settings: {
options: layerTypes.options,
},
});
if (layer) {
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) => <div>HELLO</div>,
})
.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) => <div>HELLO</div>,
})
.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;
}

View File

@ -50,29 +50,29 @@ export const carto: MapLayerRegistryItem<CartoConfig> = {
}),
});
},
}),
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];

View File

@ -66,35 +66,36 @@ export const esriXYZTiles: MapLayerRegistryItem<ESRIXYZConfig> = {
cfg.attribution = `Tiles © <a href="${base}${svc.slug}/MapServer">ArcGIS</a>`;
}
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: {

View File

@ -37,26 +37,25 @@ export const xyzTiles: MapLayerRegistryItem<XYZConfig> = {
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];

View File

@ -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<GeoJSONMapperConfig> = {
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<GeoJSONMapperConfig> = {
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) => {
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: [],
});
},
};
},
// 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: [],
});
},
defaultOptions,
};

View File

@ -110,52 +110,53 @@ export const heatmapLayer: MapLayerRegistryItem<HeatmapConfig> = {
}
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,
};

View File

@ -165,67 +165,68 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
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,

View File

@ -119,44 +119,44 @@ export const textLabelsLayer: MapLayerRegistryItem<TextLabelsConfig> = {
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,
};

View File

@ -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<GeomapPanelOptions>(GeomapPanel)
@ -32,45 +33,48 @@ export const plugin = new PanelPlugin<GeomapPanelOptions>(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: () => <div>The base layer is configured by the server admin.</div>,
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;
}
for (let i = 0; i < layerCount; i++) {
let name = 'Data layer';
if (i > 0) {
name += ` (${i + 1})`;
const selected = state.layers[state.selected];
if (state.selected && selected) {
builder.addNestedOptions(
getLayerEditor({
state: selected,
category: ['Data layer'],
basemaps: false,
})
);
}
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: () => <div>The base layer is configured by the server admin.</div>,
});
} 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

View File

@ -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<TConfig = any> {
UID: string; // value changes with each initalization
options: MapLayerOptions<TConfig>;
handler: MapLayerHandler;
layer: BaseLayer; // the openlayers instance
onChange: (cfg: MapLayerOptions<TConfig>) => void;
isBasemap?: boolean;
}
export interface StyleMakerConfig {
color: string;
fillColor: string;