From 9ce6e2a664bede006f02d48b4f392ad365802f87 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 9 Jul 2021 08:53:07 -0700 Subject: [PATCH] Geomap: add initial openlayers alpha panel (#36188) --- package.json | 2 + packages/grafana-data/src/geo/layer.ts | 67 ++++ packages/grafana-data/src/index.ts | 1 + .../PanelEditor/getVizualizationOptions.tsx | 89 ++++-- .../app/features/plugins/built_in_plugins.ts | 4 + .../plugins/panel/geomap/GeomapOverlay.tsx | 50 +++ .../app/plugins/panel/geomap/GeomapPanel.tsx | 293 ++++++++++++++++++ public/app/plugins/panel/geomap/README.md | 3 + .../panel/geomap/components/DebugOverlay.tsx | 72 +++++ .../panel/geomap/components/NumberInput.tsx | 77 +++++ .../panel/geomap/components/SimpleLegend.tsx | 96 ++++++ .../panel/geomap/editor/BaseLayerEditor.tsx | 12 + .../panel/geomap/editor/DataLayersEditor.tsx | 23 ++ .../panel/geomap/editor/LayerEditor.tsx | 111 +++++++ .../panel/geomap/editor/MapCenterEditor.tsx | 85 +++++ .../panel/geomap/editor/MapZoomEditor.tsx | 18 ++ .../app/plugins/panel/geomap/globalStyles.ts | 84 +++++ .../plugins/panel/geomap/img/icn-geomap.svg | 1 + .../panel/geomap/layers/basemaps/carto.ts | 78 +++++ .../panel/geomap/layers/basemaps/esri.ts | 107 +++++++ .../panel/geomap/layers/basemaps/generic.ts | 62 ++++ .../panel/geomap/layers/basemaps/index.ts | 22 ++ .../panel/geomap/layers/basemaps/osm.ts | 24 ++ .../panel/geomap/layers/data/geojsonMapper.ts | 59 ++++ .../plugins/panel/geomap/layers/data/index.ts | 12 + .../geomap/layers/data/lastPointTracker.ts | 78 +++++ .../geomap/layers/data/worldmapBehavior.tsx | 70 +++++ .../plugins/panel/geomap/layers/registry.ts | 12 + .../plugins/panel/geomap/migrations.test.ts | 111 +++++++ public/app/plugins/panel/geomap/migrations.ts | 102 ++++++ public/app/plugins/panel/geomap/module.tsx | 103 ++++++ public/app/plugins/panel/geomap/plugin.json | 18 ++ public/app/plugins/panel/geomap/types.ts | 52 ++++ public/app/plugins/panel/geomap/view.ts | 53 ++++ yarn.lock | 158 +++++++++- 35 files changed, 2173 insertions(+), 36 deletions(-) create mode 100644 packages/grafana-data/src/geo/layer.ts create mode 100644 public/app/plugins/panel/geomap/GeomapOverlay.tsx create mode 100644 public/app/plugins/panel/geomap/GeomapPanel.tsx create mode 100644 public/app/plugins/panel/geomap/README.md create mode 100644 public/app/plugins/panel/geomap/components/DebugOverlay.tsx create mode 100644 public/app/plugins/panel/geomap/components/NumberInput.tsx create mode 100644 public/app/plugins/panel/geomap/components/SimpleLegend.tsx create mode 100644 public/app/plugins/panel/geomap/editor/BaseLayerEditor.tsx create mode 100644 public/app/plugins/panel/geomap/editor/DataLayersEditor.tsx create mode 100644 public/app/plugins/panel/geomap/editor/LayerEditor.tsx create mode 100644 public/app/plugins/panel/geomap/editor/MapCenterEditor.tsx create mode 100644 public/app/plugins/panel/geomap/editor/MapZoomEditor.tsx create mode 100644 public/app/plugins/panel/geomap/globalStyles.ts create mode 100644 public/app/plugins/panel/geomap/img/icn-geomap.svg create mode 100644 public/app/plugins/panel/geomap/layers/basemaps/carto.ts create mode 100644 public/app/plugins/panel/geomap/layers/basemaps/esri.ts create mode 100644 public/app/plugins/panel/geomap/layers/basemaps/generic.ts create mode 100644 public/app/plugins/panel/geomap/layers/basemaps/index.ts create mode 100644 public/app/plugins/panel/geomap/layers/basemaps/osm.ts create mode 100644 public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts create mode 100644 public/app/plugins/panel/geomap/layers/data/index.ts create mode 100644 public/app/plugins/panel/geomap/layers/data/lastPointTracker.ts create mode 100644 public/app/plugins/panel/geomap/layers/data/worldmapBehavior.tsx create mode 100644 public/app/plugins/panel/geomap/layers/registry.ts create mode 100644 public/app/plugins/panel/geomap/migrations.test.ts create mode 100644 public/app/plugins/panel/geomap/migrations.ts create mode 100644 public/app/plugins/panel/geomap/module.tsx create mode 100644 public/app/plugins/panel/geomap/plugin.json create mode 100644 public/app/plugins/panel/geomap/types.ts create mode 100644 public/app/plugins/panel/geomap/view.ts diff --git a/package.json b/package.json index d6e395487d1..4ce312d5837 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-data/src/geo/layer.ts b/packages/grafana-data/src/geo/layer.ts new file mode 100644 index 00000000000..1e45da41f93 --- /dev/null +++ b/packages/grafana-data/src/geo/layer.ts @@ -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 { + 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 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, theme: GrafanaTheme2) => MapLayerHandler; + + /** + * Show custom elements in the panel edit UI + */ + registerOptionsUI?: (builder: PanelOptionsEditorBuilder) => void; +} diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 80694043234..e4d410a9a91 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -15,6 +15,7 @@ export * from './field'; export * from './events'; export * from './themes'; export * from './monaco'; +export * from './geo/layer'; export { ValueMatcherOptions, BasicValueMatcherOptions, diff --git a/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx index f0c3ce7a1c8..66030566bb7 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx @@ -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 ( - - ); - }, - }) - ); - } + // 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 +) { + 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 ( + { + onValueChanged(pluginOption.path, value); + }} + item={pluginOption} + context={context} + /> + ); + }, + }) + ); + } +} diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 4a3b32acf3c..4769cc29e49 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -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, diff --git a/public/app/plugins/panel/geomap/GeomapOverlay.tsx b/public/app/plugins/panel/geomap/GeomapOverlay.tsx new file mode 100644 index 00000000000..b7caeba1387 --- /dev/null +++ b/public/app/plugins/panel/geomap/GeomapOverlay.tsx @@ -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 { + style = getStyles(config.theme); + + constructor(props: OverlayProps) { + super(props); + } + + render() { + const { topRight, bottomLeft } = this.props; + return ( +
+ {Boolean(topRight?.length) &&
{topRight}
} + {Boolean(bottomLeft?.length) &&
{bottomLeft}
} +
+ ); + } +} + +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; + `, +})); diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx new file mode 100644 index 00000000000..f72424fe327 --- /dev/null +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -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; +export class GeomapPanel extends Component { + 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 = []; + } + + this.overlayProps = overlayProps; + } + + render() { + return ( + <> + +
+
+ +
+ + ); + } +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + wrap: css` + position: relative; + width: 100%; + height: 100%; + `, + map: css` + position: absolute; + z-index: 0; + width: 100%; + height: 100%; + `, +})); diff --git a/public/app/plugins/panel/geomap/README.md b/public/app/plugins/panel/geomap/README.md new file mode 100644 index 00000000000..b73014a84b6 --- /dev/null +++ b/public/app/plugins/panel/geomap/README.md @@ -0,0 +1,3 @@ +# Geomap Panel - Native Plugin + +The Geomap is **included** with Grafana. diff --git a/public/app/plugins/panel/geomap/components/DebugOverlay.tsx b/public/app/plugins/panel/geomap/components/DebugOverlay.tsx new file mode 100644 index 00000000000..c47d20aba4c --- /dev/null +++ b/public/app/plugins/panel/geomap/components/DebugOverlay.tsx @@ -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 { + 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 ( +
+ + + + + + + + + + + +
Zoom:{zoom?.toFixed(1)}
Center:  + {center[0].toFixed(5)}, {center[1].toFixed(5)} +
+
+ ); + } +} + +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; + `, +})); diff --git a/public/app/plugins/panel/geomap/components/NumberInput.tsx b/public/app/plugins/panel/geomap/components/NumberInput.tsx new file mode 100644 index 00000000000..674543e0cf2 --- /dev/null +++ b/public/app/plugins/panel/geomap/components/NumberInput.tsx @@ -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 { + 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) => { + 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) => { + this.setState({ + text: e.currentTarget.value, + }); + }; + + onKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + this.onBlur(e as any); + } + }; + + render() { + const { placeholder } = this.props; + const { text } = this.state; + return ( + + ); + } +} diff --git a/public/app/plugins/panel/geomap/components/SimpleLegend.tsx b/public/app/plugins/panel/geomap/components/SimpleLegend.tsx new file mode 100644 index 00000000000..4ac4522f8e9 --- /dev/null +++ b/public/app/plugins/panel/geomap/components/SimpleLegend.tsx @@ -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 { + 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 ( +
+
{this.props.txt}
+ {thresholds && ( +
+ {thresholds.steps.map((step, idx) => { + const next = thresholds!.steps[idx + 1]; + let info = ?; + if (idx === 0) { + info = < {fmt(next.value)}; + } else if (next) { + info = ( + + {fmt(step.value)} - {fmt(next.value)} + + ); + } else { + info = {fmt(step.value)} +; + } + return ( +
+ + {info} +
+ ); + })} +
+ )} +
+ ); + } +} + +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; + `, +})); diff --git a/public/app/plugins/panel/geomap/editor/BaseLayerEditor.tsx b/public/app/plugins/panel/geomap/editor/BaseLayerEditor.tsx new file mode 100644 index 00000000000..16dbbc4cfd7 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/BaseLayerEditor.tsx @@ -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> = ({ + value, + onChange, + context, +}) => { + return Boolean(v.isBaseMap)} />; +}; diff --git a/public/app/plugins/panel/geomap/editor/DataLayersEditor.tsx b/public/app/plugins/panel/geomap/editor/DataLayersEditor.tsx new file mode 100644 index 00000000000..3a33c302992 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/DataLayersEditor.tsx @@ -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> = ({ + value, + onChange, + context, +}) => { + return ( + { + console.log('Change overlays:', cfg); + onChange([cfg]); + }} + filter={(v) => !v.isBaseMap} + /> + ); +}; diff --git a/public/app/plugins/panel/geomap/editor/LayerEditor.tsx b/public/app/plugins/panel/geomap/editor/LayerEditor.tsx new file mode 100644 index 00000000000..441bd8d42d8 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/LayerEditor.tsx @@ -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 { + config?: MapLayerConfig; + data: DataFrame[]; // All results + onChange: (config: MapLayerConfig) => void; + filter: (item: MapLayerRegistryItem) => boolean; +} + +export const LayerEditor: FC = ({ 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 = { + 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 ( + <> +
+ {category.items.map((item) => item.render())} + + ); + }, [optionsEditorBuilder, onChange, data, config]); + + return ( +
+ { + onChange({ + id: v.value!, + }); + }} + /> + {value?.id === MapCenterID.Coordinates && ( +
+ + + + + + + + + + + +
LatitudeLongitude
+ { + onChange({ ...value, lat: v }); + }} + /> + + { + onChange({ ...value, lon: v }); + }} + /> +
+
+ )} +
+ ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + table: css` + width: 100%; + margin-top: 8px; + `, + half: css` + width: 50%; + `, +})); diff --git a/public/app/plugins/panel/geomap/editor/MapZoomEditor.tsx b/public/app/plugins/panel/geomap/editor/MapZoomEditor.tsx new file mode 100644 index 00000000000..651871b443a --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/MapZoomEditor.tsx @@ -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> = ({ + value, + onChange, + context, +}) => { + // TODO: + // Somehow use context to get the current map and listen to zoom changes + return ( +
+ +
+ ); +}; diff --git a/public/app/plugins/panel/geomap/globalStyles.ts b/public/app/plugins/panel/geomap/globalStyles.ts new file mode 100644 index 00000000000..2bee9b9db74 --- /dev/null +++ b/public/app/plugins/panel/geomap/globalStyles.ts @@ -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); + } + `; +} diff --git a/public/app/plugins/panel/geomap/img/icn-geomap.svg b/public/app/plugins/panel/geomap/img/icn-geomap.svg new file mode 100644 index 00000000000..2d904f3b095 --- /dev/null +++ b/public/app/plugins/panel/geomap/img/icn-geomap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/geomap/layers/basemaps/carto.ts b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts new file mode 100644 index 00000000000..574a7de5a7d --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts @@ -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 = { + 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, 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: `© CARTO`, + 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]; diff --git a/public/app/plugins/panel/geomap/layers/basemaps/esri.ts b/public/app/plugins/panel/geomap/layers/basemaps/esri.ts new file mode 100644 index 00000000000..7e03cfcb0c3 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/esri.ts @@ -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(() => [ + { + 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 = { + id: 'esri-xyz', + name: 'ArcGIS MapServer', + isBaseMap: true, + + create: (map: Map, options: MapLayerConfig, 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 © ArcGIS`; + } + // 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]; diff --git a/public/app/plugins/panel/geomap/layers/basemaps/generic.ts b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts new file mode 100644 index 00000000000..01b0dcdf51a --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts @@ -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 © ArcGIS`, +}; + +export const xyzTiles: MapLayerRegistryItem = { + id: 'xyz', + name: 'XYZ Tile layer', + isBaseMap: true, + + create: (map: Map, options: MapLayerConfig, 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]; diff --git a/public/app/plugins/panel/geomap/layers/basemaps/index.ts b/public/app/plugins/panel/geomap/layers/basemaps/index.ts new file mode 100644 index 00000000000..76f77ce1581 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/index.ts @@ -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, +]; diff --git a/public/app/plugins/panel/geomap/layers/basemaps/osm.ts b/public/app/plugins/panel/geomap/layers/basemaps/osm.ts new file mode 100644 index 00000000000..4fe5ca41bf6 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/osm.ts @@ -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]; diff --git a/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts b/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts new file mode 100644 index 00000000000..7641af90236 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts @@ -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 = { + 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, 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, +}; diff --git a/public/app/plugins/panel/geomap/layers/data/index.ts b/public/app/plugins/panel/geomap/layers/data/index.ts new file mode 100644 index 00000000000..cc904791eec --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/data/index.ts @@ -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 +]; diff --git a/public/app/plugins/panel/geomap/layers/data/lastPointTracker.ts b/public/app/plugins/panel/geomap/layers/data/lastPointTracker.ts new file mode 100644 index 00000000000..7954ddbfb6e --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/data/lastPointTracker.ts @@ -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 = { + 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, 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, +}; diff --git a/public/app/plugins/panel/geomap/layers/data/worldmapBehavior.tsx b/public/app/plugins/panel/geomap/layers/data/worldmapBehavior.tsx new file mode 100644 index 00000000000..3c6f08fe1b5 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/data/worldmapBehavior.tsx @@ -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 = { + 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, theme: GrafanaTheme2): MapLayerHandler => { + // const config = { ...defaultOptions, ...options.config }; + const vectorLayer = new layer.Vector({}); + let legendInstance = ; + 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 = ; + + const vectorSource = new source.Vector({ features }); + vectorLayer.setSource(vectorSource); + }, + }; + }, + + // fill in the default values + defaultOptions, +}; diff --git a/public/app/plugins/panel/geomap/layers/registry.ts b/public/app/plugins/panel/geomap/layers/registry.ts new file mode 100644 index 00000000000..bb79974ddbe --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/registry.ts @@ -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>(() => [ + ...basemapLayers, // simple basemaps + ...dataLayers, // Layers with update functions +]); diff --git a/public/app/plugins/panel/geomap/migrations.test.ts b/public/app/plugins/panel/geomap/migrations.test.ts new file mode 100644 index 00000000000..c14f26c2b63 --- /dev/null +++ b/public/app/plugins/panel/geomap/migrations.test.ts @@ -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, +}; diff --git a/public/app/plugins/panel/geomap/migrations.ts b/public/app/plugins/panel/geomap/migrations.ts new file mode 100644 index 00000000000..5b3ca894b7c --- /dev/null +++ b/public/app/plugins/panel/geomap/migrations.ts @@ -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; +} diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx new file mode 100644 index 00000000000..c609aa2ba99 --- /dev/null +++ b/public/app/plugins/panel/geomap/module.tsx @@ -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(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, + }); + }); diff --git a/public/app/plugins/panel/geomap/plugin.json b/public/app/plugins/panel/geomap/plugin.json new file mode 100644 index 00000000000..ae10418ab07 --- /dev/null +++ b/public/app/plugins/panel/geomap/plugin.json @@ -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" + } + } +} diff --git a/public/app/plugins/panel/geomap/types.ts b/public/app/plugins/panel/geomap/types.ts new file mode 100644 index 00000000000..42bc87a49db --- /dev/null +++ b/public/app/plugins/panel/geomap/types.ts @@ -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[]; +} diff --git a/public/app/plugins/panel/geomap/view.ts b/public/app/plugins/panel/geomap/view.ts new file mode 100644 index 00000000000..c7b30ec912f --- /dev/null +++ b/public/app/plugins/panel/geomap/view.ts @@ -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(() => [ + { + 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', + }, +]); diff --git a/yarn.lock b/yarn.lock index 0efd232b7d4..c433dbdf2b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"