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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2173 additions and 36 deletions

View File

@ -226,6 +226,7 @@
"@sentry/browser": "5.25.0",
"@sentry/types": "5.24.2",
"@sentry/utils": "5.24.2",
"@types/ol": "^6.5.1",
"@welldone-software/why-did-you-render": "4.0.6",
"abortcontroller-polyfill": "1.4.0",
"angular": "1.8.2",
@ -263,6 +264,7 @@
"moment-timezone": "0.5.33",
"mousetrap": "1.6.5",
"mousetrap-global-bind": "1.1.0",
"ol": "^6.5.0",
"papaparse": "5.3.0",
"pluralize": "^8.0.0",
"prismjs": "1.23.0",

View File

@ -0,0 +1,67 @@
import { RegistryItemWithOptions } from '../utils/Registry';
import BaseLayer from 'ol/layer/Base';
import Map from 'ol/Map';
import { PanelData } from '../types';
import { GrafanaTheme2 } from '../themes';
import { PanelOptionsEditorBuilder } from '../utils';
import { ReactNode } from 'react';
/**
* This gets saved in panel json
*
* depending on the type, it may have additional config
*
* This exists in `grafana/data` so the types are well known and extendable but the
* layout/frame is control by the map panel
*
* @alpha
*/
export interface MapLayerConfig<TCustom = any> {
type: string;
name?: string; // configured display name
// Common properties:
// https://openlayers.org/en/latest/apidoc/module-ol_layer_Base-BaseLayer.html
// Layer opacity (0-1)
opacity?: number;
// Custom options depending on the type
config?: TCustom;
}
/**
* @alpha
*/
export interface MapLayerHandler {
init: () => BaseLayer;
legend?: () => ReactNode;
update?: (data: PanelData) => void;
}
/**
* Map layer configuration
*
* @alpha
*/
export interface MapLayerRegistryItem<TConfig = MapLayerConfig> extends RegistryItemWithOptions {
/**
* This layer can be used as a background
*/
isBaseMap?: boolean;
/**
* Show transparency controls in UI (for non-basemaps)
*/
showTransparency?: boolean;
/**
* Function that configures transformation and returns a transformer
* @param options
*/
create: (map: Map, options: MapLayerConfig<TConfig>, theme: GrafanaTheme2) => MapLayerHandler;
/**
* Show custom elements in the panel edit UI
*/
registerOptionsUI?: (builder: PanelOptionsEditorBuilder<TConfig>) => void;
}

View File

@ -15,6 +15,7 @@ export * from './field';
export * from './events';
export * from './themes';
export * from './monaco';
export * from './geo/layer';
export {
ValueMatcherOptions,
BasicValueMatcherOptions,

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',
},
]);

158
yarn.lock
View File

@ -3336,6 +3336,35 @@
npmlog "^4.1.2"
write-file-atomic "^2.3.0"
"@mapbox/jsonlint-lines-primitives@~2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234"
integrity sha1-zlblOfg1UrWNENZy6k1vya3HsjQ=
"@mapbox/mapbox-gl-style-spec@^13.14.0":
version "13.20.1"
resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-13.20.1.tgz#bcf7c42836025a831a76a1e1348ea549c36daf55"
integrity sha512-xVCJ3IbKoPwcPrxxtGAxUqHEVxXi1hnJtLIFqgkuZfnzj0KeRbk3dZlDr/KNo1/doJjIoFgPFUO/HMOT+wXGPA==
dependencies:
"@mapbox/jsonlint-lines-primitives" "~2.0.2"
"@mapbox/point-geometry" "^0.1.0"
"@mapbox/unitbezier" "^0.0.0"
csscolorparser "~1.0.2"
json-stringify-pretty-compact "^2.0.0"
minimist "^1.2.5"
rw "^1.3.3"
sort-object "^0.3.2"
"@mapbox/point-geometry@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz#8a83f9335c7860effa2eeeca254332aa0aeed8f2"
integrity sha1-ioP5M1x4YO/6Lu7KJUMyqgru2PI=
"@mapbox/unitbezier@^0.0.0":
version "0.0.0"
resolved "https://registry.yarnpkg.com/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz#15651bd553a67b8581fb398810c98ad86a34524e"
integrity sha1-FWUb1VOme4WB+zmIEMmK2Go0Uk4=
"@mdx-js/loader@^1.6.22":
version "1.6.22"
resolved "https://registry.yarnpkg.com/@mdx-js/loader/-/loader-1.6.22.tgz#d9e8fe7f8185ff13c9c8639c048b123e30d322c4"
@ -4702,6 +4731,11 @@
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a"
integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==
"@types/arcgis-rest-api@*":
version "10.4.4"
resolved "https://registry.yarnpkg.com/@types/arcgis-rest-api/-/arcgis-rest-api-10.4.4.tgz#c84b26ac7f01deb2829bff10ebbe544c2310fbcd"
integrity sha512-5NwSfj4po+03fauyr4F5AxYzu8pbbqmxay+pNr5ef2V3Mj+7OylvV48VKuVoO9m799jhZdH3EQgQBHm3Y6q1Sw==
"@types/argparse@1.0.38":
version "1.0.38"
resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9"
@ -5142,7 +5176,7 @@
dependencies:
"@types/node" "*"
"@types/geojson@*":
"@types/geojson@*", "@types/geojson@^7946.0.7":
version "7946.0.7"
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.7.tgz#c8fa532b60a0042219cdf173ca21a975ef0666ad"
integrity sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ==
@ -5443,6 +5477,16 @@
resolved "https://registry.yarnpkg.com/@types/npmlog/-/npmlog-4.1.2.tgz#d070fe6a6b78755d1092a3dc492d34c3d8f871c4"
integrity sha512-4QQmOF5KlwfxJ5IGXFIudkeLCdMABz03RcUXu+LCb24zmln8QW6aDjuGl4d4XPVLf2j+FnjelHTP7dvceAFbhA==
"@types/ol@^6.5.1":
version "6.5.1"
resolved "https://registry.yarnpkg.com/@types/ol/-/ol-6.5.1.tgz#148def5e5370da4fdf0da65b0e8657c816d79ed8"
integrity sha512-SHQjTKZ0s5EvhSnI0nyXEhmS/Ez+045c0TvSad0bZmwgldOsVTCG4b43G7q9dHjN+FQIET1Y4s/i2JFDsEZqEA==
dependencies:
"@types/arcgis-rest-api" "*"
"@types/geojson" "*"
"@types/rbush" "*"
"@types/topojson-specification" "*"
"@types/overlayscrollbars@^1.12.0":
version "1.12.0"
resolved "https://registry.yarnpkg.com/@types/overlayscrollbars/-/overlayscrollbars-1.12.0.tgz#98456caceca8ad73bd5bb572632a585074e70764"
@ -5524,6 +5568,11 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
"@types/rbush@*":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/rbush/-/rbush-3.0.0.tgz#b6887d99b159e87ae23cd14eceff34f139842aa6"
integrity sha512-W3ue/GYWXBOpkRm0VSoifrP3HV0Ni47aVJWvXyWMcbtpBy/l/K/smBRiJ+fI8f7shXRjZBiux+iJzYbh7VmcZg==
"@types/reach__router@^1.3.7":
version "1.3.7"
resolved "https://registry.yarnpkg.com/@types/reach__router/-/reach__router-1.3.7.tgz#de8ab374259ae7f7499fc1373b9697a5f3cd6428"
@ -5870,6 +5919,13 @@
resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.1.0.tgz#19cf73a7bcf641965485119726397a096f0049bd"
integrity sha512-6IwZ9HzWbCq6XoQWhxLpDjuADodH/MKXRUIDFudvgjcVdjFknvmR+DNsoUeer4XPrEnrZs04Jj+kfV9pFsrhmA==
"@types/topojson-specification@*":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/topojson-specification/-/topojson-specification-1.0.1.tgz#a80cb294290b79f2d674d3f5938c544ed2bd9d80"
integrity sha512-ZZYZUgkmUls9Uhxx2WZNt9f/h2+H3abUUjOVmq+AaaDFckC5oAwd+MDp95kBirk+XCXrYj0hfpI6DSUiJMrpYQ==
dependencies:
"@types/geojson" "*"
"@types/uglify-js@*":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
@ -9319,6 +9375,11 @@ css@^3.0.0:
source-map "^0.6.1"
source-map-resolve "^0.6.0"
csscolorparser@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b"
integrity sha1-s085HupNqPPpgjHizNjfnAQfFxs=
cssdb@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-4.4.0.tgz#3bf2f2a68c10f5c6a08abd92378331ee803cddb0"
@ -13311,6 +13372,11 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
dependencies:
postcss "^7.0.14"
ieee754@^1.1.12:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ieee754@^1.1.4:
version "1.1.13"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
@ -15034,6 +15100,11 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1:
dependencies:
jsonify "~0.0.0"
json-stringify-pretty-compact@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz#e77c419f52ff00c45a31f07f4c820c2433143885"
integrity sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==
json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
@ -15864,6 +15935,11 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
mapbox-to-css-font@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/mapbox-to-css-font/-/mapbox-to-css-font-2.4.0.tgz#a23b51664a1ee839beaefade013f2655bee9a390"
integrity sha512-v674D0WtpxCXlA6E+sBlG1QJWdUkz/s9qAD91bJSXBGuBL5lL4tJXpoJEftecphCh2SVQCjWMS2vhylc3AIQTg==
markdown-escapes@^1.0.0:
version "1.0.4"
resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535"
@ -17129,6 +17205,29 @@ octokit-pagination-methods@^1.1.0:
resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
ol-layerswitcher@^3.8.3:
version "3.8.3"
resolved "https://registry.yarnpkg.com/ol-layerswitcher/-/ol-layerswitcher-3.8.3.tgz#c27c7a7152849a968941a6b7aae978fda8bdfea3"
integrity sha512-UwUhalf/sGXjz3rvr0EjwsaUVlJAhyJCfcIPciKk1QdNbMKq/2ZXNKGafOjwP2eDxiqhkvnhpIrDGD8+gQ19Cg==
ol-mapbox-style@^6.1.1:
version "6.3.2"
resolved "https://registry.yarnpkg.com/ol-mapbox-style/-/ol-mapbox-style-6.3.2.tgz#5cd1cbb41ecd697d3488fd928976def108a41d3b"
integrity sha512-itWZuwZHilztRM9983WmJ+ounaXIS0PdXF8h5xJd7cJhSv02M27w4RQkhiUw35/VLlUdTT/ei3KYi0w2TGDw2A==
dependencies:
"@mapbox/mapbox-gl-style-spec" "^13.14.0"
mapbox-to-css-font "^2.4.0"
webfont-matcher "^1.1.0"
ol@^6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/ol/-/ol-6.5.0.tgz#d9cd59081ac34dc4caf0509c3f667748a8207a21"
integrity sha512-a5ebahrjF5yCPFle1rc0aHzKp/9A4LlUnjh+S3I+x4EgcvcddDhpOX3WDOs0Pg9/wEElrikHSGEvbeej2Hh4Ug==
dependencies:
ol-mapbox-style "^6.1.1"
pbf "3.2.1"
rbush "^3.0.1"
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@ -17724,6 +17823,14 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
pbf@3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.2.1.tgz#b4c1b9e72af966cd82c6531691115cc0409ffe2a"
integrity sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==
dependencies:
ieee754 "^1.1.12"
resolve-protobuf-schema "^2.1.0"
pbkdf2@^3.0.3:
version "3.0.17"
resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6"
@ -18912,6 +19019,11 @@ protobufjs@^6.10.2:
"@types/node" "^13.7.0"
long "^4.0.0"
protocol-buffers-schema@^3.3.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.5.1.tgz#8388e768d383ac8cbea23e1280dfadb79f4122ad"
integrity sha512-YVCvdhxWNDP8/nJDyXLuM+UFsuPk4+1PB7WGPVDzm3HTHbzFLxQYeW2iZpS4mmnXrQJGBzt230t/BbEb7PrQaw==
protocols@^1.1.0, protocols@^1.4.0:
version "1.4.8"
resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.8.tgz#48eea2d8f58d9644a4a32caae5d5db290a075ce8"
@ -19097,6 +19209,11 @@ quick-lru@^4.0.1:
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
quickselect@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-2.0.0.tgz#f19680a486a5eefb581303e023e98faaf25dd018"
integrity sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==
raf-schd@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0"
@ -19170,6 +19287,13 @@ raw-loader@4.0.2, raw-loader@^4.0.2:
loader-utils "^2.0.0"
schema-utils "^3.0.0"
rbush@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/rbush/-/rbush-3.0.1.tgz#5fafa8a79b3b9afdfe5008403a720cc1de882ecf"
integrity sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==
dependencies:
quickselect "^2.0.0"
rc-align@^2.4.0:
version "2.4.5"
resolved "https://registry.yarnpkg.com/rc-align/-/rc-align-2.4.5.tgz#c941a586f59d1017f23a428f0b468663fb7102ab"
@ -20549,6 +20673,13 @@ resolve-pathname@^3.0.0:
resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd"
integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==
resolve-protobuf-schema@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz#9ca9a9e69cf192bbdaf1006ec1973948aa4a3758"
integrity sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==
dependencies:
protocol-buffers-schema "^3.3.1"
resolve-url@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
@ -20788,7 +20919,7 @@ run-queue@^1.0.0, run-queue@^1.0.3:
dependencies:
aproba "^1.1.1"
rw@1:
rw@1, rw@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=
@ -21445,6 +21576,16 @@ socks@~2.3.2:
ip "1.1.5"
smart-buffer "^4.1.0"
sort-asc@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/sort-asc/-/sort-asc-0.1.0.tgz#ab799df61fc73ea0956c79c4b531ed1e9e7727e9"
integrity sha1-q3md9h/HPqCVbHnEtTHtHp53J+k=
sort-desc@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/sort-desc/-/sort-desc-0.1.1.tgz#198b8c0cdeb095c463341861e3925d4ee359a9ee"
integrity sha1-GYuMDN6wlcRjNBhh45JdTuNZqe4=
sort-keys@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
@ -21459,6 +21600,14 @@ sort-keys@^2.0.0:
dependencies:
is-plain-obj "^1.0.0"
sort-object@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/sort-object/-/sort-object-0.3.2.tgz#98e0d199ede40e07c61a84403c61d6c3b290f9e2"
integrity sha1-mODRme3kDgfGGoRAPGHWw7KQ+eI=
dependencies:
sort-asc "^0.1.0"
sort-desc "^0.1.1"
source-list-map@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
@ -23566,6 +23715,11 @@ web-namespaces@^1.0.0:
resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec"
integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==
webfont-matcher@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/webfont-matcher/-/webfont-matcher-1.1.0.tgz#98ce95097b29e31fbe733053e10e571642d1c6c7"
integrity sha1-mM6VCXsp4x++czBT4Q5XFkLRxsc=
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"