Geomap: add initial openlayers alpha panel (#36188)

This commit is contained in:
Ryan McKinley
2021-07-09 08:53:07 -07:00
committed by GitHub
parent e4ece0530a
commit 9ce6e2a664
35 changed files with 2173 additions and 36 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { StandardEditorContext, VariableSuggestionsScope } from '@grafana/data';
import { PanelOptionsEditorItem, StandardEditorContext, VariableSuggestionsScope } from '@grafana/data';
import { get as lodashGet } from 'lodash';
import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
import { OptionPaneRenderProps } from './types';
@@ -7,6 +7,8 @@ import { updateDefaultFieldConfigValue, setOptionImmutably } from './utils';
import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor';
import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
type categoryGetter = (categoryNames?: string[]) => OptionsPaneCategoryDescriptor;
export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor[] {
const { plugin, panel, onPanelOptionsChanged, onFieldConfigsChange, data, dashboard } = props;
const currentOptions = panel.getOptions();
@@ -37,39 +39,16 @@ export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPa
}));
};
/**
* Panel options
*/
for (const pluginOption of plugin.optionEditors.list()) {
if (pluginOption.showIf && !pluginOption.showIf(currentOptions, data?.series)) {
continue;
}
const category = getOptionsPaneCategory(pluginOption.category);
const Editor = pluginOption.editor;
category.addItem(
new OptionsPaneItemDescriptor({
title: pluginOption.name,
description: pluginOption.description,
render: function renderEditor() {
const onChange = (value: any) => {
const newOptions = setOptionImmutably(currentOptions, pluginOption.path, value);
onPanelOptionsChanged(newOptions);
};
return (
<Editor
value={lodashGet(currentOptions, pluginOption.path)}
onChange={onChange}
item={pluginOption}
context={context}
/>
);
},
})
);
}
// Load the options into categories
fillOptionsPaneItems(
plugin.optionEditors.list(),
getOptionsPaneCategory,
(path: string, value: any) => {
const newOptions = setOptionImmutably(context.options, path, value);
onPanelOptionsChanged(newOptions);
},
context
);
/**
* Field options
@@ -120,3 +99,45 @@ export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPa
return Object.values(categoryIndex);
}
/**
* This will iterate all options panes and add register them with the configured categories
*
* @internal
*/
export function fillOptionsPaneItems(
optionEditors: PanelOptionsEditorItem[],
getOptionsPaneCategory: categoryGetter,
onValueChanged: (path: string, value: any) => void,
context: StandardEditorContext<any>
) {
for (const pluginOption of optionEditors) {
if (pluginOption.showIf && !pluginOption.showIf(context.options, context.data)) {
continue;
}
const category = getOptionsPaneCategory(pluginOption.category);
const Editor = pluginOption.editor;
// TODO? can some options recursivly call: fillOptionsPaneItems?
category.addItem(
new OptionsPaneItemDescriptor({
title: pluginOption.name,
description: pluginOption.description,
render: function renderEditor() {
return (
<Editor
value={lodashGet(context.options, pluginOption.path)}
onChange={(value: any) => {
onValueChanged(pluginOption.path, value);
}}
item={pluginOption}
context={context}
/>
);
},
})
);
}
}

View File

@@ -67,6 +67,9 @@ import * as welcomeBanner from 'app/plugins/panel/welcome/module';
import * as nodeGraph from 'app/plugins/panel/nodeGraph/module';
import * as histogramPanel from 'app/plugins/panel/histogram/module';
// Async loaded panels
const geomapPanel = async () => await import(/* webpackChunkName: "geomapPanel" */ 'app/plugins/panel/geomap/module');
const builtInPlugins: any = {
'app/plugins/datasource/graphite/module': graphitePlugin,
'app/plugins/datasource/cloudwatch/module': cloudwatchPlugin,
@@ -95,6 +98,7 @@ const builtInPlugins: any = {
'app/plugins/panel/status-history/module': statusHistoryPanel,
'app/plugins/panel/graph/module': graphPanel,
'app/plugins/panel/xychart/module': xyChartPanel,
'app/plugins/panel/geomap/module': geomapPanel,
'app/plugins/panel/dashlist/module': dashListPanel,
'app/plugins/panel/pluginlist/module': pluginsListPanel,
'app/plugins/panel/alertlist/module': alertListPanel,

View File

@@ -0,0 +1,50 @@
import React, { PureComponent } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { config } from '@grafana/runtime';
import { stylesFactory } from '@grafana/ui';
import { css } from '@emotion/css';
export interface OverlayProps {
topRight?: React.ReactNode[];
bottomLeft?: React.ReactNode[];
}
export class GeomapOverlay extends PureComponent<OverlayProps> {
style = getStyles(config.theme);
constructor(props: OverlayProps) {
super(props);
}
render() {
const { topRight, bottomLeft } = this.props;
return (
<div className={this.style.overlay}>
{Boolean(topRight?.length) && <div className={this.style.TR}>{topRight}</div>}
{Boolean(bottomLeft?.length) && <div className={this.style.BL}>{bottomLeft}</div>}
</div>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
overlay: css`
position: absolute;
width: 100%;
height: 100%;
z-index: 500;
pointer-events: none;
`,
TR: css`
position: absolute;
top: 8px;
right: 8px;
pointer-events: auto;
`,
BL: css`
position: absolute;
bottom: 8px;
left: 8px;
pointer-events: auto;
`,
}));

View File

@@ -0,0 +1,293 @@
import React, { Component } from 'react';
import { geomapLayerRegistry } from './layers/registry';
import { Map, 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, MapLayerConfig, PanelProps, GrafanaTheme } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ControlsOptions, GeomapPanelOptions, MapViewConfig } from './types';
import { defaultGrafanaThemedMap } from './layers/basemaps';
import { centerPointRegistry, MapCenterID } from './view';
import { fromLonLat } from 'ol/proj';
import { Coordinate } from 'ol/coordinate';
import { css } from '@emotion/css';
import { stylesFactory } from '@grafana/ui';
import { GeomapOverlay, OverlayProps } from './GeomapOverlay';
import { DebugOverlay } from './components/DebugOverlay';
import { getGlobalStyles } from './globalStyles';
import { Global } from '@emotion/react';
interface MapLayerState {
config: MapLayerConfig;
handler: MapLayerHandler;
layer: BaseLayer; // used to add|remove
}
// Allows multiple panels to share the same view instance
let sharedView: View | undefined = undefined;
type Props = PanelProps<GeomapPanelOptions>;
export class GeomapPanel extends Component<Props> {
globalCSS = getGlobalStyles(config.theme2);
map: Map;
basemap: BaseLayer;
layers: MapLayerState[] = [];
mouseWheelZoom: MouseWheelZoom;
style = getStyles(config.theme);
overlayProps: OverlayProps = {};
shouldComponentUpdate(nextProps: Props) {
if (!this.map) {
return true; // not yet initalized
}
// Check for resize
if (this.props.height !== nextProps.height || this.props.width !== nextProps.width) {
this.map.updateSize();
}
// External configuraiton 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) {
this.dataChanged(nextProps.data, nextProps.options.controls.showLegend);
}
return true; // always?
}
/**
* Called when the panel options change
*/
optionsChanged(options: GeomapPanelOptions): boolean {
let layersChanged = false;
const oldOptions = this.props.options;
console.log('options changed!', options);
if (options.view !== oldOptions.view) {
console.log('View changed');
this.map.setView(this.initMapView(options.view));
}
if (options.controls !== oldOptions.controls) {
console.log('Crontrols 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 ?? []);
layersChanged = true;
}
return layersChanged;
}
/**
* Called when PanelData changes (query results etc)
*/
dataChanged(data: PanelData, showLegend?: boolean) {
const legends: React.ReactNode[] = [];
for (const state of this.layers) {
if (state.handler.update) {
state.handler.update(data);
}
if (showLegend && state.handler.legend) {
legends.push(state.handler.legend());
}
}
this.overlayProps.bottomLeft = legends;
}
initMapRef = (div: HTMLDivElement) => {
if (this.map) {
this.map.dispose();
}
if (!div) {
this.map = (undefined as unknown) as Map;
return;
}
const { options } = this.props;
this.map = new Map({
view: this.initMapView(options.view),
pixelRatio: 1, // or zoom?
layers: [], // loaded explicitly below
controls: [],
target: div,
interactions: interactionDefaults({
mouseWheelZoom: false, // managed by initControls
}),
});
this.mouseWheelZoom = new MouseWheelZoom();
this.map.addInteraction(this.mouseWheelZoom);
this.initControls(options.controls);
this.initBasemap(options.basemap);
this.initLayers(options.layers);
this.dataChanged(this.props.data, options.controls.showLegend);
this.forceUpdate(); // first render
};
initBasemap(cfg: MapLayerConfig) {
if (!cfg) {
cfg = { type: defaultGrafanaThemedMap.id };
}
const item = geomapLayerRegistry.getIfExists(cfg.type) ?? defaultGrafanaThemedMap;
const layer = item.create(this.map, cfg, config.theme2).init();
if (this.basemap) {
this.map.removeLayer(this.basemap);
this.basemap.dispose();
}
this.basemap = layer;
this.map.getLayers().insertAt(0, this.basemap);
}
initLayers(layers: MapLayerConfig[]) {
// 1st remove existing layers
for (const state of this.layers) {
this.map.removeLayer(state.layer);
state.layer.dispose();
}
if (!layers) {
layers = [];
}
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 = item.create(this.map, overlay, config.theme2);
const layer = handler.init();
this.map.addLayer(layer);
this.layers.push({
config: overlay,
layer,
handler,
});
}
}
initMapView(config: MapViewConfig): View {
let view = new View({
center: [0, 0],
zoom: 1,
});
// With shared views, all panels use the same view instance
if (config.shared) {
if (!sharedView) {
sharedView = view;
} else {
view = sharedView;
}
}
const v = centerPointRegistry.getIfExists(config.center.id);
if (v) {
let coord: Coordinate | undefined = undefined;
if (v.lat == null) {
if (v.id === MapCenterID.Coordinates) {
const center = config.center ?? {};
coord = [center.lon ?? 0, center.lat ?? 0];
} else {
console.log('TODO, view requires special handling', v);
}
} 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) {
view.setZoom(config.zoom);
}
return view;
}
initControls(options: ControlsOptions) {
this.map.getControls().clear();
if (options.showZoom) {
this.map.addControl(new Zoom());
}
if (options.showScale) {
this.map.addControl(
new ScaleLine({
units: options.scaleUnits,
minWidth: 100,
})
);
}
this.mouseWheelZoom.setActive(Boolean(options.mouseWheelZoom));
if (options.showAttribution) {
this.map.addControl(new Attribution({ collapsed: true, collapsible: true }));
}
// Update the react overlays
const overlayProps: OverlayProps = {};
if (options.showDebug) {
overlayProps.topRight = [<DebugOverlay key="debug" map={this.map} />];
}
this.overlayProps = overlayProps;
}
render() {
return (
<>
<Global styles={this.globalCSS} />
<div className={this.style.wrap}>
<div className={this.style.map} ref={this.initMapRef}></div>
<GeomapOverlay {...this.overlayProps} />
</div>
</>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
wrap: css`
position: relative;
width: 100%;
height: 100%;
`,
map: css`
position: absolute;
z-index: 0;
width: 100%;
height: 100%;
`,
}));

View File

@@ -0,0 +1,3 @@
# Geomap Panel - Native Plugin
The Geomap is **included** with Grafana.

View File

@@ -0,0 +1,72 @@
import React, { PureComponent } from 'react';
import { Map } from 'ol';
import { transform } from 'ol/proj';
import { stylesFactory } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { css } from '@emotion/css';
import { config } from 'app/core/config';
import tinycolor from 'tinycolor2';
import { Coordinate } from 'ol/coordinate';
interface Props {
map: Map;
}
interface State {
zoom?: number;
center: Coordinate;
}
export class DebugOverlay extends PureComponent<Props, State> {
style = getStyles(config.theme);
constructor(props: Props) {
super(props);
this.state = { zoom: 0, center: [0, 0] };
}
updateViewState = () => {
const view = this.props.map.getView();
this.setState({
zoom: view.getZoom(),
center: transform(view.getCenter()!, view.getProjection(), 'EPSG:4326'),
});
};
componentDidMount() {
this.props.map.on('moveend', this.updateViewState);
this.updateViewState();
}
render() {
const { zoom, center } = this.state;
return (
<div className={this.style.infoWrap}>
<table>
<tbody>
<tr>
<th>Zoom:</th>
<td>{zoom?.toFixed(1)}</td>
</tr>
<tr>
<th>Center:&nbsp;</th>
<td>
{center[0].toFixed(5)}, {center[1].toFixed(5)}
</td>
</tr>
</tbody>
</table>
</div>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
infoWrap: css`
color: ${theme.colors.text};
background: ${tinycolor(theme.colors.panelBg).setAlpha(0.7).toString()};
border-radius: 2px;
padding: 8px;
`,
}));

View File

@@ -0,0 +1,77 @@
import React, { PureComponent } from 'react';
import { Input } from '@grafana/ui';
interface Props {
value?: number;
placeholder?: string;
autoFocus?: boolean;
onChange: (number?: number) => void;
min?: number;
max?: number;
step?: number;
}
interface State {
text: string;
}
/**
* This is an Input field that will call `onChange` for blur and enter
*/
export class NumberInput extends PureComponent<Props, State> {
state: State = { text: '' };
componentDidMount() {
this.setState({
text: isNaN(this.props.value!) ? '' : `${this.props.value}`,
});
}
componentDidUpdate(oldProps: Props) {
if (this.props.value !== oldProps.value) {
this.setState({
text: isNaN(this.props.value!) ? '' : `${this.props.value}`,
});
}
}
onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
let value: number | undefined = undefined;
const txt = e.currentTarget.value;
if (txt && !isNaN(e.currentTarget.valueAsNumber)) {
value = e.currentTarget.valueAsNumber;
}
this.props.onChange(value);
};
onChange = (e: React.FocusEvent<HTMLInputElement>) => {
this.setState({
text: e.currentTarget.value,
});
};
onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
this.onBlur(e as any);
}
};
render() {
const { placeholder } = this.props;
const { text } = this.state;
return (
<Input
type="number"
min={this.props.min}
max={this.props.max}
step={this.props.step}
autoFocus={this.props.autoFocus}
value={text}
onChange={this.onChange}
onBlur={this.onBlur}
onKeyPress={this.onKeyPress}
placeholder={placeholder}
/>
);
}
}

View File

@@ -0,0 +1,96 @@
import React, { PureComponent } from 'react';
import { stylesFactory } from '@grafana/ui';
import { FieldType, formattedValueToString, GrafanaTheme, PanelData, ThresholdsConfig } from '@grafana/data';
import { css } from '@emotion/css';
import { config } from 'app/core/config';
import tinycolor from 'tinycolor2';
interface Props {
txt: string;
data?: PanelData;
}
interface State {}
export class SimpleLegend extends PureComponent<Props, State> {
style = getStyles(config.theme);
constructor(props: Props) {
super(props);
this.state = {};
}
render() {
let fmt = (v: any) => `${v}`;
let thresholds: ThresholdsConfig | undefined;
const series = this.props.data?.series;
if (series) {
for (const frame of series) {
for (const field of frame.fields) {
if (field.type === FieldType.number && field.config.thresholds) {
thresholds = field.config.thresholds;
fmt = (v: any) => `${formattedValueToString(field.display!(v))}`;
break;
}
}
}
}
return (
<div className={this.style.infoWrap}>
<div>{this.props.txt}</div>
{thresholds && (
<div className={this.style.legend}>
{thresholds.steps.map((step, idx) => {
const next = thresholds!.steps[idx + 1];
let info = <span>?</span>;
if (idx === 0) {
info = <span>&lt; {fmt(next.value)}</span>;
} else if (next) {
info = (
<span>
{fmt(step.value)} - {fmt(next.value)}
</span>
);
} else {
info = <span>{fmt(step.value)} +</span>;
}
return (
<div key={`${idx}/${step.value}`} className={this.style.legendItem}>
<i style={{ background: config.theme2.visualization.getColorByName(step.color) }}></i>
{info}
</div>
);
})}
</div>
)}
</div>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
infoWrap: css`
color: ${theme.colors.text};
background: ${tinycolor(theme.colors.panelBg).setAlpha(0.7).toString()};
border-radius: 2px;
padding: 8px;
`,
legend: css`
line-height: 18px;
color: #555;
display: flex;
flex-direction: column;
i {
width: 18px;
height: 18px;
float: left;
margin-right: 8px;
opacity: 0.7;
}
`,
legendItem: css`
white-space: nowrap;
`,
}));

View File

@@ -0,0 +1,12 @@
import React, { FC } from 'react';
import { StandardEditorProps, MapLayerConfig } from '@grafana/data';
import { GeomapPanelOptions } from '../types';
import { LayerEditor } from './LayerEditor';
export const BaseLayerEditor: FC<StandardEditorProps<MapLayerConfig, any, GeomapPanelOptions>> = ({
value,
onChange,
context,
}) => {
return <LayerEditor config={value} data={context.data} onChange={onChange} filter={(v) => Boolean(v.isBaseMap)} />;
};

View File

@@ -0,0 +1,23 @@
import React, { FC } from 'react';
import { StandardEditorProps, MapLayerConfig } from '@grafana/data';
import { GeomapPanelOptions } from '../types';
import { LayerEditor } from './LayerEditor';
// For now this supports a *single* data layer -- eventually we should support more than one
export const DataLayersEditor: FC<StandardEditorProps<MapLayerConfig[], any, GeomapPanelOptions>> = ({
value,
onChange,
context,
}) => {
return (
<LayerEditor
config={value?.length ? value[0] : undefined}
data={context.data}
onChange={(cfg) => {
console.log('Change overlays:', cfg);
onChange([cfg]);
}}
filter={(v) => !v.isBaseMap}
/>
);
};

View File

@@ -0,0 +1,111 @@
import React, { FC, useMemo } from 'react';
import { Select } from '@grafana/ui';
import {
MapLayerConfig,
DataFrame,
MapLayerRegistryItem,
PanelOptionsEditorBuilder,
StandardEditorContext,
} from '@grafana/data';
import { geomapLayerRegistry } from '../layers/registry';
import { defaultGrafanaThemedMap } from '../layers/basemaps';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVizualizationOptions';
export interface LayerEditorProps<TConfig = any> {
config?: MapLayerConfig<TConfig>;
data: DataFrame[]; // All results
onChange: (config: MapLayerConfig<TConfig>) => void;
filter: (item: MapLayerRegistryItem) => boolean;
}
export const LayerEditor: FC<LayerEditorProps> = ({ config, onChange, data, filter }) => {
// all basemaps
const layerTypes = useMemo(() => {
return geomapLayerRegistry.selectOptions(
config?.type // the selected value
? [config.type] // as an array
: [defaultGrafanaThemedMap.id],
filter
);
}, [config?.type, filter]);
// The options change with each layer type
const optionsEditorBuilder = useMemo(() => {
const layer = geomapLayerRegistry.getIfExists(config?.type);
if (!layer || !layer.registerOptionsUI) {
return null;
}
const builder = new PanelOptionsEditorBuilder();
layer.registerOptionsUI(builder);
return builder;
}, [config?.type]);
// The react componnets
const layerOptions = useMemo(() => {
const layer = geomapLayerRegistry.getIfExists(config?.type);
if (!optionsEditorBuilder || !layer) {
return null;
}
const category = new OptionsPaneCategoryDescriptor({
id: 'Layer config',
title: 'Layer config',
});
const context: StandardEditorContext<any> = {
data,
options: config?.config,
};
const currentConfig = { ...layer.defaultOptions, ...config?.config };
const reg = optionsEditorBuilder.getRegistry();
// Load the options into categories
fillOptionsPaneItems(
reg.list(),
// Always use the same category
(categoryNames) => category,
// Custom upate function
(path: string, value: any) => {
onChange({
...config,
config: setOptionImmutably(currentConfig, path, value),
} as MapLayerConfig);
},
context
);
return (
<>
<br />
{category.items.map((item) => item.render())}
</>
);
}, [optionsEditorBuilder, onChange, data, config]);
return (
<div>
<Select
options={layerTypes.options}
value={layerTypes.current}
onChange={(v) => {
const layer = geomapLayerRegistry.getIfExists(v.value);
if (!layer) {
console.warn('layer does not exist', v);
return;
}
onChange({
type: layer.id,
config: layer.defaultOptions, // clone?
});
}}
/>
{layerOptions}
</div>
);
};

View File

@@ -0,0 +1,85 @@
import React, { FC, useMemo } from 'react';
import { GrafanaTheme, StandardEditorProps } from '@grafana/data';
import { Select, stylesFactory, useStyles } from '@grafana/ui';
import { GeomapPanelOptions, MapCenterConfig } from '../types';
import { centerPointRegistry, MapCenterID } from '../view';
import { css } from '@emotion/css';
import { NumberInput } from '../components/NumberInput';
export const MapCenterEditor: FC<StandardEditorProps<MapCenterConfig, any, GeomapPanelOptions>> = ({
value,
onChange,
context,
}) => {
const style = useStyles(getStyles);
const views = useMemo(() => {
const ids: string[] = [];
if (value?.id) {
ids.push(value.id);
} else {
ids.push(centerPointRegistry.list()[0].id);
}
return centerPointRegistry.selectOptions(ids);
}, [value?.id]);
return (
<div>
<Select
options={views.options}
value={views.current}
onChange={(v) => {
onChange({
id: v.value!,
});
}}
/>
{value?.id === MapCenterID.Coordinates && (
<div>
<table className={style.table}>
<tbody>
<tr>
<th className={style.half}>Latitude</th>
<th className={style.half}>Longitude</th>
</tr>
<tr>
<td>
<NumberInput
value={value.lat}
min={-90}
max={90}
placeholder="0"
onChange={(v) => {
onChange({ ...value, lat: v });
}}
/>
</td>
<td>
<NumberInput
value={value.lon}
min={-180}
max={180}
placeholder="0"
onChange={(v) => {
onChange({ ...value, lon: v });
}}
/>
</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
table: css`
width: 100%;
margin-top: 8px;
`,
half: css`
width: 50%;
`,
}));

View File

@@ -0,0 +1,18 @@
import React, { FC } from 'react';
import { StandardEditorProps } from '@grafana/data';
import { GeomapPanelOptions } from '../types';
import { NumberInput } from '../components/NumberInput';
export const MapZoomEditor: FC<StandardEditorProps<number | undefined, any, GeomapPanelOptions>> = ({
value,
onChange,
context,
}) => {
// TODO:
// Somehow use context to get the current map and listen to zoom changes
return (
<div>
<NumberInput value={value} min={1} max={30} onChange={onChange} />
</div>
);
};

View File

@@ -0,0 +1,84 @@
import { css } from '@emotion/react';
import { GrafanaTheme2 } from '@grafana/data';
import 'ol/ol.css';
import tinycolor from 'tinycolor2';
/**
* Will be loaded *after* the css above
*/
export function getGlobalStyles(theme: GrafanaTheme2) {
// NOTE: this works with
// node_modules/ol/ol.css
// use !important;
// This file keeps the rules
// .ol-box {
// border: 2px solid blue;
// }
// .ol-scale-step-marker {
// background-color: #000000;
// }
// .ol-scale-step-text {
// color: #000000;
// text-shadow: -2px 0 #FFFFFF, 0 2px #FFFFFF, 2px 0 #FFFFFF, 0 -2px #FFFFFF;
// }
// .ol-scale-text {
// color: #000000;
// text-shadow: -2px 0 #FFFFFF, 0 2px #FFFFFF, 2px 0 #FFFFFF, 0 -2px #FFFFFF;
// }
// .ol-scale-singlebar {
// border: 1px solid black;
// }
// .ol-viewport, .ol-unselectable {
// -webkit-tap-highlight-color: rgba(0,0,0,0);
// }
// .ol-overviewmap .ol-overviewmap-map {
// border: 1px solid #7b98bc;
// }
// .ol-overviewmap:not(.ol-collapsed) {
// background: rgba(255,255,255,0.8);
// }
// .ol-overviewmap-box {
// border: 2px dotted rgba(0,60,136,0.7);
// }
const bg = tinycolor(theme.v1.colors.panelBg);
const button = tinycolor(theme.colors.secondary.main);
return css`
.ol-scale-line {
background: ${bg.setAlpha(0.7).toRgbString()}; // rgba(0,60,136,0.3);
}
.ol-scale-line-inner {
border: 1px solid ${theme.colors.text.primary}; // #eee;
border-top: 0px;
color: ${theme.colors.text.primary}; // #eee;
}
.ol-control {
background-color: ${bg.setAlpha(0.4).toRgbString()}; //rgba(255,255,255,0.4);
}
.ol-control:hover {
background-color: ${bg.setAlpha(0.6).toRgbString()}; // rgba(255,255,255,0.6);
}
.ol-control button {
color: ${bg.setAlpha(0.8).toRgbString()}; // white;
background-color: ${button.setAlpha(0.5).toRgbString()}; // rgba(0,60,136,0.5);
}
.ol-control button:hover {
background-color: ${button.setAlpha(0.7).toRgbString()}; // rgba(0,60,136,0.7);
}
.ol-control button:focus {
// same as button
background-color: ${button.setAlpha(0.5).toRgbString()}; // rgba(0,60,136,0.5);
}
.ol-attribution ul {
color: ${theme.colors.text.primary}; // #000;
text-shadow: 0 0 0px #fff; // removes internal styling!
}
.ol-attribution:not(.ol-collapsed) {
background-color: ${bg.setAlpha(0.8).toRgbString()}; // rgba(255,255,255,0.8);
}
`;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 79.8 82.08"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:#fff;}.cls-4{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="23.91" y1="19.31" x2="55.89" y2="19.31" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M54,30.13,39.9,44.27h0V82.08l20-10V16.88A19.85,19.85,0,0,1,54,30.13Z"/><path class="cls-2" d="M25.76,30.13A19.89,19.89,0,0,1,20,16.69V72.11l20,10V44.27Z"/><path class="cls-1" d="M20,72.11,1.45,81.36A1,1,0,0,1,0,80.47V26.18a1,1,0,0,1,.55-.89L20,15.6Z"/><path class="cls-2" d="M78.35,81.36l-18.5-9.25V15.6l19.4,9.69a1,1,0,0,1,.55.89V80.47A1,1,0,0,1,78.35,81.36Z"/><path class="cls-3" d="M68.37,37.9a2.26,2.26,0,0,0,.79.5,2.39,2.39,0,0,0,1.82,0,2.17,2.17,0,0,0,.79-.5,2.55,2.55,0,0,0,.51-.79,2.42,2.42,0,0,0-.51-2.62A2.71,2.71,0,0,0,71,34a2.22,2.22,0,0,0-1.82,0,2.71,2.71,0,0,0-.79.5,2.42,2.42,0,0,0-.51,2.62A2.55,2.55,0,0,0,68.37,37.9Z"/><path class="cls-3" d="M68.37,67.57a2.44,2.44,0,0,0,.79.51,2.39,2.39,0,0,0,1.82,0,2.23,2.23,0,0,0,1.3-1.3,2.41,2.41,0,0,0-.51-2.61,3,3,0,0,0-.79-.51,2.3,2.3,0,0,0-1.82,0,3,3,0,0,0-.79.51,2.41,2.41,0,0,0-.51,2.61A2.34,2.34,0,0,0,68.37,67.57Z"/><path class="cls-3" d="M48.17,47.39a2.52,2.52,0,0,0,.79.5,2.42,2.42,0,0,0,1.83,0,2.52,2.52,0,0,0,.79-.5,2.3,2.3,0,0,0,.5-.8,2.38,2.38,0,0,0-.5-2.61,2.71,2.71,0,0,0-.79-.5,2.24,2.24,0,0,0-1.83,0,2.85,2.85,0,0,0-.79.5,2.38,2.38,0,0,0-.5,2.61A2.3,2.3,0,0,0,48.17,47.39Z"/><path class="cls-3" d="M28,67.57a2.55,2.55,0,0,0,.79.51,2.39,2.39,0,0,0,1.82,0,2.23,2.23,0,0,0,1.3-1.3,2.41,2.41,0,0,0-.51-2.61,3,3,0,0,0-.79-.51,2.3,2.3,0,0,0-1.82,0,3,3,0,0,0-.79.51,2.38,2.38,0,0,0-.5,2.61A2.17,2.17,0,0,0,28,67.57Z"/><path class="cls-3" d="M8.27,37.9a2.35,2.35,0,0,0,.79.5,2.42,2.42,0,0,0,1.83,0,2.13,2.13,0,0,0,1.29-1.29,2.4,2.4,0,0,0-.5-2.62,2.85,2.85,0,0,0-.79-.5,2.24,2.24,0,0,0-1.83,0,2.85,2.85,0,0,0-.79.5,2.4,2.4,0,0,0-.5,2.62A2.35,2.35,0,0,0,8.27,37.9Z"/><path class="cls-4" d="M51.21,4.68A16,16,0,0,0,28.59,27.3L39.9,38.61,51.21,27.3a16,16,0,0,0,0-22.62ZM45.38,17.17a5.38,5.38,0,0,1-3.21,3.21,5.9,5.9,0,0,1-4.52,0,6,6,0,0,1-2-1.25,6,6,0,0,1-1.25-2,6,6,0,0,1,1.25-6.48,6.79,6.79,0,0,1,2-1.25,5.56,5.56,0,0,1,4.52,0,6.74,6.74,0,0,1,2,1.25A5.94,5.94,0,0,1,45.38,17.17Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,78 @@
import { MapLayerRegistryItem, MapLayerConfig, GrafanaTheme2 } from '@grafana/data';
import Map from 'ol/Map';
import XYZ from 'ol/source/XYZ';
import TileLayer from 'ol/layer/Tile';
// https://carto.com/help/building-maps/basemap-list/
export enum LayerTheme {
Auto = 'auto',
Light = 'light',
Dark = 'dark',
}
export interface CartoConfig {
theme?: LayerTheme;
showLabels?: boolean;
}
export const defaultCartoConfig: CartoConfig = {
theme: LayerTheme.Auto,
showLabels: true,
};
export const carto: MapLayerRegistryItem<CartoConfig> = {
id: 'carto',
name: 'CARTO reference map',
isBaseMap: true,
defaultOptions: defaultCartoConfig,
/**
* Function that configures transformation and returns a transformer
* @param options
*/
create: (map: Map, options: MapLayerConfig<CartoConfig>, theme: GrafanaTheme2) => ({
init: () => {
const cfg = { ...defaultCartoConfig, ...options.config };
let style = cfg.theme as string;
if (!style || style === LayerTheme.Auto) {
style = theme.isDark ? 'dark' : 'light';
}
if (cfg.showLabels) {
style += '_all';
} else {
style += '_nolabels';
}
return new TileLayer({
source: new XYZ({
attributions: `<a href="https://carto.com/attribution/">© CARTO</a>`,
url: `https://{1-4}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}.png`,
}),
});
},
}),
registerOptionsUI: (builder) => {
builder
.addRadio({
path: '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: 'showLabels',
name: 'Show labels',
description: '',
defaultValue: defaultCartoConfig.showLabels,
});
},
};
export const cartoLayers = [carto];

View File

@@ -0,0 +1,107 @@
import { MapLayerRegistryItem, MapLayerConfig, GrafanaTheme2, RegistryItem, Registry } from '@grafana/data';
import Map from 'ol/Map';
import { xyzTiles, defaultXYZConfig, XYZConfig } from './generic';
interface PublicServiceItem extends RegistryItem {
slug: string;
}
const CUSTOM_SERVICE = 'custom';
const DEFAULT_SERVICE = 'streets';
export const publicServiceRegistry = new Registry<PublicServiceItem>(() => [
{
id: DEFAULT_SERVICE,
name: 'World Street Map',
slug: 'World_Street_Map',
},
{
id: 'world-imagery',
name: 'World Imagery',
slug: 'World_Imagery',
},
{
id: 'world-physical',
name: 'World Physical',
slug: 'World_Physical_Map',
},
{
id: 'topo',
name: 'Topographic',
slug: 'World_Topo_Map',
},
{
id: 'usa-topo',
name: 'USA Topographic',
slug: 'USA_Topo_Maps',
},
{
id: 'ocean',
name: 'World Ocean',
slug: 'Ocean/World_Ocean_Base',
},
{
id: CUSTOM_SERVICE,
name: 'Custom MapServer',
description: 'Use a custom MapServer with pre-cached values',
slug: '',
},
]);
export interface ESRIXYZConfig extends XYZConfig {
server: string;
}
export const esriXYZTiles: MapLayerRegistryItem<ESRIXYZConfig> = {
id: 'esri-xyz',
name: 'ArcGIS MapServer',
isBaseMap: true,
create: (map: Map, options: MapLayerConfig<ESRIXYZConfig>, theme: GrafanaTheme2) => ({
init: () => {
const cfg = { ...options.config };
const svc = publicServiceRegistry.getIfExists(cfg.server ?? DEFAULT_SERVICE)!;
if (svc.id !== CUSTOM_SERVICE) {
const base = 'https://services.arcgisonline.com/ArcGIS/rest/services/';
cfg.url = `${base}${svc.slug}/MapServer/tile/{z}/{y}/{x}`;
cfg.attribution = `Tiles © <a href="${base}${svc.slug}/MapServer">ArcGIS</a>`;
}
// reuse the standard XYZ tile logic
return xyzTiles.create(map, { ...options, config: cfg as XYZConfig }, theme).init();
},
}),
registerOptionsUI: (builder) => {
builder
.addSelect({
path: 'server',
name: 'Server instance',
settings: {
options: publicServiceRegistry.selectOptions().options,
},
})
.addTextInput({
path: 'url',
name: 'URL template',
description: 'Must include {x}, {y} or {-y}, and {z} placeholders',
settings: {
placeholder: defaultXYZConfig.url,
},
showIf: (cfg) => cfg.server === CUSTOM_SERVICE,
})
.addTextInput({
path: 'attribution',
name: 'Attribution',
settings: {
placeholder: defaultXYZConfig.attribution,
},
showIf: (cfg) => cfg.server === CUSTOM_SERVICE,
});
},
defaultOptions: {
server: DEFAULT_SERVICE,
},
};
export const esriLayers = [esriXYZTiles];

View File

@@ -0,0 +1,62 @@
import { MapLayerRegistryItem, MapLayerConfig, GrafanaTheme2 } from '@grafana/data';
import Map from 'ol/Map';
import XYZ from 'ol/source/XYZ';
import TileLayer from 'ol/layer/Tile';
export interface XYZConfig {
url: string;
attribution: string;
minZoom?: number;
maxZoom?: number;
}
const sampleURL = 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer';
export const defaultXYZConfig: XYZConfig = {
url: sampleURL + '/tile/{z}/{y}/{x}',
attribution: `Tiles © <a href="${sampleURL}">ArcGIS</a>`,
};
export const xyzTiles: MapLayerRegistryItem<XYZConfig> = {
id: 'xyz',
name: 'XYZ Tile layer',
isBaseMap: true,
create: (map: Map, options: MapLayerConfig<XYZConfig>, theme: GrafanaTheme2) => ({
init: () => {
const cfg = { ...options.config };
if (!cfg.url) {
cfg.url = defaultXYZConfig.url;
cfg.attribution = cfg.attribution ?? defaultXYZConfig.attribution;
}
return new TileLayer({
source: new XYZ({
url: cfg.url,
attributions: cfg.attribution, // singular?
}),
minZoom: cfg.minZoom,
maxZoom: cfg.maxZoom,
});
},
}),
registerOptionsUI: (builder) => {
builder
.addTextInput({
path: 'url',
name: 'URL template',
description: 'Must include {x}, {y} or {-y}, and {z} placeholders',
settings: {
placeholder: defaultXYZConfig.url,
},
})
.addTextInput({
path: 'attribution',
name: 'Attribution',
settings: {
placeholder: defaultXYZConfig.attribution,
},
});
},
};
export const genericLayers = [xyzTiles];

View File

@@ -0,0 +1,22 @@
import { cartoLayers, carto } from './carto';
import { esriLayers } from './esri';
import { genericLayers } from './generic';
import { osmLayers } from './osm';
// For now just use carto
export const defaultGrafanaThemedMap = {
...carto,
id: 'default',
name: 'Default base layer',
};
/**
* Registry for layer handlers
*/
export const basemapLayers = [
defaultGrafanaThemedMap,
...osmLayers,
...cartoLayers,
...esriLayers, // keep formatting
...genericLayers,
];

View File

@@ -0,0 +1,24 @@
import { MapLayerRegistryItem, MapLayerConfig } from '@grafana/data';
import Map from 'ol/Map';
import OSM from 'ol/source/OSM';
import TileLayer from 'ol/layer/Tile';
const standard: MapLayerRegistryItem = {
id: 'osm-standard',
name: 'Open Street Map',
isBaseMap: true,
/**
* Function that configures transformation and returns a transformer
* @param options
*/
create: (map: Map, options: MapLayerConfig) => ({
init: () => {
return new TileLayer({
source: new OSM(),
});
},
}),
};
export const osmLayers = [standard];

View File

@@ -0,0 +1,59 @@
import { MapLayerRegistryItem, MapLayerConfig, MapLayerHandler, PanelData, GrafanaTheme2 } from '@grafana/data';
import Map from 'ol/Map';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import GeoJSON from 'ol/format/GeoJSON';
export interface GeoJSONMapperConfig {
// URL for a geojson file
src?: string;
// Field name that will map to each featureId
idField?: string;
// Field to use that will set color
valueField?: string;
}
const defaultOptions: GeoJSONMapperConfig = {
src: 'https://openlayers.org/en/latest/examples/data/geojson/countries.geojson',
};
export const geojsonMapper: MapLayerRegistryItem<GeoJSONMapperConfig> = {
id: 'geojson-value-mapper',
name: 'Map values to GeoJSON file',
description: 'color features based on query results',
isBaseMap: false,
/**
* Function that configures transformation and returns a transformer
* @param options
*/
create: (map: Map, options: MapLayerConfig<GeoJSONMapperConfig>, theme: GrafanaTheme2): MapLayerHandler => {
const config = { ...defaultOptions, ...options.config };
const source = new VectorSource({
url: config.src,
format: new GeoJSON(),
});
const vectorLayer = new VectorLayer({
source,
});
return {
init: () => vectorLayer,
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() );
});
},
};
},
// fill in the default values
defaultOptions,
};

View File

@@ -0,0 +1,12 @@
import { geojsonMapper } from './geojsonMapper';
import { lastPointTracker } from './lastPointTracker';
import { worldmapBehaviorLayer } from './worldmapBehavior';
/**
* Registry for layer handlers
*/
export const dataLayers = [
worldmapBehaviorLayer, // mimic the existing worldmap
lastPointTracker,
geojsonMapper, // dummy for now
];

View File

@@ -0,0 +1,78 @@
import { MapLayerRegistryItem, MapLayerConfig, MapLayerHandler, PanelData, Field, GrafanaTheme2 } from '@grafana/data';
import Map from 'ol/Map';
import Feature from 'ol/Feature';
import * as style from 'ol/style';
import * as source from 'ol/source';
import * as layer from 'ol/layer';
import Point from 'ol/geom/Point';
import { fromLonLat } from 'ol/proj';
export interface LastPointConfig {
icon?: string;
}
const defaultOptions: LastPointConfig = {
icon: 'https://openlayers.org/en/latest/examples/data/icon.png',
};
export const lastPointTracker: MapLayerRegistryItem<LastPointConfig> = {
id: 'last-point-tracker',
name: 'Icon at last point',
description: 'Show an icon at the last point',
isBaseMap: false,
/**
* Function that configures transformation and returns a transformer
* @param options
*/
create: (map: Map, options: MapLayerConfig<LastPointConfig>, theme: GrafanaTheme2): MapLayerHandler => {
const point = new Feature({});
const config = { ...defaultOptions, ...options.config };
point.setStyle(
new style.Style({
image: new style.Icon({
src: config.icon,
}),
})
);
const vectorSource = new source.Vector({
features: [point],
});
const vectorLayer = new layer.Vector({
source: vectorSource,
});
return {
init: () => vectorLayer,
update: (data: PanelData) => {
const frame = data.series[0];
if (frame && frame.length) {
let lat: Field | undefined = undefined;
let lng: Field | undefined = undefined;
for (const field of frame.fields) {
if (field.name === 'lat') {
lat = field;
} else if (field.name === 'lng') {
lng = field;
}
}
if (lat && lng) {
const idx = lat.values.length - 1;
const latV = lat.values.get(idx);
const lngV = lng.values.get(idx);
if (latV != null && lngV != null) {
point.setGeometry(new Point(fromLonLat([lngV, latV])));
}
}
}
},
};
},
// fill in the default values
defaultOptions,
};

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { MapLayerRegistryItem, MapLayerConfig, MapLayerHandler, PanelData, GrafanaTheme2 } from '@grafana/data';
import Map from 'ol/Map';
import Feature from 'ol/Feature';
import * as layer from 'ol/layer';
import * as source from 'ol/source';
import * as style from 'ol/style';
import {Point} from 'ol/geom';
import { fromLonLat } from 'ol/proj';
import tinycolor from 'tinycolor2';
import { SimpleLegend } from '../../components/SimpleLegend';
export interface WorldmapConfig {
// anything
}
const defaultOptions: WorldmapConfig = {
// icon: 'https://openlayers.org/en/latest/examples/data/icon.png',
};
export const worldmapBehaviorLayer: MapLayerRegistryItem<WorldmapConfig> = {
id: 'worldmap-behavior',
name: 'Worldmap behavior',
description: 'behave the same as worldmap plugin',
isBaseMap: false,
/**
* Function that configures transformation and returns a transformer
* @param options
*/
create: (map: Map, options: MapLayerConfig<WorldmapConfig>, theme: GrafanaTheme2): MapLayerHandler => {
// const config = { ...defaultOptions, ...options.config };
const vectorLayer = new layer.Vector({});
let legendInstance = <SimpleLegend txt={ `initalizing...`}/>;
let count = 0;
return {
init: () => vectorLayer,
legend: () => {
return legendInstance;
},
update: (data: PanelData) => {
count++;
const features:Feature[] = [];
for( let x=0; x<100; x+=20) {
for( let y=0; y<40; y+=10) {
const dot = new Feature({
geometry: new Point(fromLonLat([x,y])),
});
dot.setStyle(new style.Style({
image: new style.Circle({
fill: new style.Fill({
color: tinycolor({r:(x*2), g:(y*3), b:0}).toString(),
}),
radius: (4 + (y*0.5) + (x*0.1)),
})
}));
features.push(dot);
}
}
legendInstance = <SimpleLegend txt={ `Update: ${count}`} data={data}/>;
const vectorSource = new source.Vector({ features });
vectorLayer.setSource(vectorSource);
},
};
},
// fill in the default values
defaultOptions,
};

View File

@@ -0,0 +1,12 @@
import { MapLayerRegistryItem, Registry } from '@grafana/data';
import { basemapLayers } from './basemaps';
import { dataLayers } from './data';
/**
* Registry for layer handlers
*/
export const geomapLayerRegistry = new Registry<MapLayerRegistryItem<any>>(() => [
...basemapLayers, // simple basemaps
...dataLayers, // Layers with update functions
]);

View File

@@ -0,0 +1,111 @@
import { PanelModel, FieldConfigSource } from '@grafana/data';
import { mapPanelChangedHandler } from './migrations';
describe('Worldmap Migrations', () => {
let prevFieldConfig: FieldConfigSource;
beforeEach(() => {
prevFieldConfig = {
defaults: {},
overrides: [],
};
});
it('simple worldmap', () => {
const old: any = {
angular: simpleWorldmapConfig,
};
const panel = {} as PanelModel;
panel.options = mapPanelChangedHandler(panel, 'grafana-worldmap-panel', old, prevFieldConfig);
expect(panel).toMatchInlineSnapshot(`
Object {
"fieldConfig": Object {
"defaults": Object {
"decimals": 3,
"thresholds": Object {
"mode": "absolute",
"steps": Array [
Object {
"color": "#37872D",
"value": -Infinity,
},
Object {
"color": "#E0B400",
"value": 0,
},
Object {
"color": "#C4162A",
"value": 50,
},
Object {
"color": "#8F3BB8",
"value": 100,
},
],
},
},
"overrides": Array [],
},
"options": Object {
"basemap": Object {
"type": "default",
},
"controls": Object {
"mouseWheelZoom": true,
"showLegend": true,
"showZoom": true,
},
"layers": Array [],
"view": Object {
"center": Object {
"id": "europe",
"lat": 46,
"lon": 14,
},
"zoom": 6,
},
},
}
`);
});
});
const simpleWorldmapConfig = {
id: 23763571993,
gridPos: {
h: 8,
w: 12,
x: 0,
y: 0,
},
type: 'grafana-worldmap-panel',
title: 'Panel Title',
thresholds: '0,50,100',
maxDataPoints: 1,
circleMaxSize: 30,
circleMinSize: 2,
colors: ['#37872D', '#E0B400', '#C4162A', '#8F3BB8'],
decimals: 3,
esMetric: 'Count',
hideEmpty: false,
hideZero: false,
initialZoom: '6',
locationData: 'countries',
mapCenter: 'Europe',
mapCenterLatitude: 46,
mapCenterLongitude: 14,
mouseWheelZoom: true,
showLegend: true,
stickyLabels: false,
tableQueryOptions: {
geohashField: 'geohash',
latitudeField: 'latitude',
longitudeField: 'longitude',
metricField: 'metric',
queryType: 'geohash',
},
unitPlural: '',
unitSingle: '',
valueName: 'total',
datasource: null,
};

View File

@@ -0,0 +1,102 @@
import { FieldConfigSource, PanelTypeChangedHandler, Threshold, ThresholdsMode } from '@grafana/data';
import { GeomapPanelOptions } from './types';
import { MapCenterID } from './view';
/**
* This is called when the panel changes from another panel
*/
export const mapPanelChangedHandler: PanelTypeChangedHandler = (panel, prevPluginId, prevOptions, prevFieldConfig) => {
// Changing from angular/worldmap panel to react/openlayers
if (prevPluginId === 'grafana-worldmap-panel' && prevOptions.angular) {
const { fieldConfig, options } = worldmapToGeomapOptions({
...prevOptions.angular,
fieldConfig: prevFieldConfig,
});
panel.fieldConfig = fieldConfig; // Mutates the incoming panel
return options;
}
return {};
};
export function worldmapToGeomapOptions(angular: any): { fieldConfig: FieldConfigSource; options: GeomapPanelOptions } {
const fieldConfig: FieldConfigSource = {
defaults: {},
overrides: [],
};
const options: GeomapPanelOptions = {
view: {
center: {
id: MapCenterID.Zero,
},
},
controls: {
showZoom: true,
showLegend: Boolean(angular.showLegend),
mouseWheelZoom: Boolean(angular.mouseWheelZoom),
},
basemap: {
type: 'default', // was carto
},
layers: [
// TODO? depends on current configs
],
};
let v = asNumber(angular.decimals);
if (v) {
fieldConfig.defaults.decimals = v;
}
// Convert thresholds and color values
if (angular.thresholds && angular.colors) {
const levels = angular.thresholds.split(',').map((strVale: string) => {
return Number(strVale.trim());
});
// One more color than threshold
const thresholds: Threshold[] = [];
for (const color of angular.colors) {
const idx = thresholds.length - 1;
if (idx >= 0) {
thresholds.push({ value: levels[idx], color });
} else {
thresholds.push({ value: -Infinity, color });
}
}
fieldConfig.defaults.thresholds = {
mode: ThresholdsMode.Absolute,
steps: thresholds,
};
}
v = asNumber(angular.initialZoom);
if (v) {
options.view.zoom = v;
}
// mapCenter: 'Europe',
// mapCenterLatitude: 46,
// mapCenterLongitude: 14,
//
// Map center (from worldmap)
const mapCenters: any = {
'(0°, 0°)': MapCenterID.Zero,
'North America': 'north-america',
Europe: 'europe',
'West Asia': 'west-asia',
'SE Asia': 'se-asia',
'Last GeoHash': MapCenterID.LastPoint,
};
options.view.center.id = mapCenters[angular.mapCenter as any];
options.view.center.lat = asNumber(angular.mapCenterLatitude);
options.view.center.lon = asNumber(angular.mapCenterLongitude);
return { fieldConfig, options };
}
function asNumber(v: any): number | undefined {
const num = +v;
return isNaN(num) ? undefined : num;
}

View File

@@ -0,0 +1,103 @@
import { PanelPlugin } from '@grafana/data';
import { BaseLayerEditor } from './editor/BaseLayerEditor';
import { DataLayersEditor } from './editor/DataLayersEditor';
import { GeomapPanel } from './GeomapPanel';
import { MapCenterEditor } from './editor/MapCenterEditor';
import { defaultView, GeomapPanelOptions } from './types';
import { MapZoomEditor } from './editor/MapZoomEditor';
import { mapPanelChangedHandler } from './migrations';
export const plugin = new PanelPlugin<GeomapPanelOptions>(GeomapPanel)
.setNoPadding()
.setPanelChangeHandler(mapPanelChangedHandler)
.useFieldConfig()
.setPanelOptions((builder) => {
let category = ['Map View'];
builder.addCustomEditor({
category,
id: 'view.center',
path: 'view.center',
name: 'Center',
editor: MapCenterEditor,
defaultValue: defaultView.center,
});
builder.addCustomEditor({
category,
id: 'view.zoom',
path: 'view.zoom',
name: 'Initial zoom',
editor: MapZoomEditor,
defaultValue: defaultView.zoom,
});
builder.addBooleanSwitch({
category,
path: 'view.shared',
description: 'Use the same view across multiple panels. Note: this may require a dashboard reload.',
name: 'Share view',
defaultValue: defaultView.shared,
});
// Nested
builder.addCustomEditor({
category: ['Base Layer'],
id: 'basemap',
path: 'basemap',
name: 'Base Layer',
editor: BaseLayerEditor,
});
builder.addCustomEditor({
category: ['Data Layer'],
id: 'layers',
path: 'layers',
name: 'Data Layer',
editor: DataLayersEditor,
});
// The controls section
category = ['Map Controls'];
builder
.addBooleanSwitch({
category,
path: 'controls.showZoom',
description: 'show buttons in the upper left',
name: 'Show zoom control',
defaultValue: true,
})
.addBooleanSwitch({
category,
path: 'controls.mouseWheelZoom',
name: 'Mouse wheel zoom',
defaultValue: true,
})
.addBooleanSwitch({
category,
path: 'controls.showLegend',
name: 'Show legend',
description: 'Show legend',
defaultValue: true,
})
.addBooleanSwitch({
category,
path: 'controls.showAttribution',
name: 'Show attribution',
description: 'Show the map source attribution info in the lower right',
defaultValue: true,
})
.addBooleanSwitch({
category,
path: 'controls.showScale',
name: 'Show scale',
description: 'Indicate map scale',
defaultValue: false,
})
.addBooleanSwitch({
category,
path: 'controls.showDebug',
name: 'Show debug',
description: 'show map info',
defaultValue: false,
});
});

View File

@@ -0,0 +1,18 @@
{
"type": "panel",
"name": "Geomap",
"id": "geomap",
"state": "alpha",
"info": {
"description": "Geomap panel",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "img/icn-geomap.svg",
"large": "img/icn-geomap.svg"
}
}
}

View File

@@ -0,0 +1,52 @@
import { MapLayerConfig } from '@grafana/data';
import Units from 'ol/proj/Units';
import { MapCenterID } from './view';
export interface ControlsOptions {
// Zoom (upper left)
showZoom?: boolean;
// let the mouse wheel zoom
mouseWheelZoom?: boolean;
// Add legend control
showLegend?: boolean;
// Lower right
showAttribution?: boolean;
// Scale options
showScale?: boolean;
scaleUnits?: Units;
// Show debug
showDebug?: boolean;
}
export interface MapCenterConfig {
id: string; // placename > lookup
lat?: number;
lon?: number;
}
export interface MapViewConfig {
center: MapCenterConfig;
zoom?: number;
minZoom?: number;
maxZoom?: number;
shared?: boolean;
}
export const defaultView: MapViewConfig = {
center: {
id: MapCenterID.Zero,
},
zoom: 1,
};
export interface GeomapPanelOptions {
view: MapViewConfig;
controls: ControlsOptions;
basemap: MapLayerConfig;
layers: MapLayerConfig[];
}

View File

@@ -0,0 +1,53 @@
import { Registry, RegistryItem } from '@grafana/data';
interface MapCenterItems extends RegistryItem {
lat?: number;
lon?: number;
}
export enum MapCenterID {
Zero = 'zero',
Coordinates = 'coords',
LastPoint = 'last',
}
export const centerPointRegistry = new Registry<MapCenterItems>(() => [
{
id: MapCenterID.Zero as string,
name: '(0°, 0°)',
lat: 0,
lon: 0,
},
{
id: 'north-america',
name: 'North America',
lat: 40,
lon: -100,
},
{
id: 'europe',
name: 'Europe',
lat: 46,
lon: 14,
},
{
id: 'west-asia',
name: 'West Asia',
lat: 26,
lon: 53,
},
{
id: 'se-asia',
name: 'South-east Asia',
lat: 10,
lon: 106,
},
{
id: MapCenterID.Coordinates as string,
name: 'Coordinates',
},
{
id: MapCenterID.LastPoint as string,
name: 'Last value',
},
]);