mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Geomap: configure legend on map (#37077)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
parent
5f0bc252bc
commit
154c380c8c
@ -68,8 +68,8 @@ export interface MapLayerOptions<TConfig = any> {
|
||||
*/
|
||||
export interface MapLayerHandler {
|
||||
init: () => BaseLayer;
|
||||
legend?: () => ReactNode;
|
||||
update?: (data: PanelData) => void;
|
||||
legend?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry, defaultBaseLayer } from './layers/registry';
|
||||
import { Map, View } from 'ol';
|
||||
import Attribution from 'ol/control/Attribution';
|
||||
@ -33,16 +33,20 @@ let sharedView: View | undefined = undefined;
|
||||
export let lastGeomapPanelInstance: GeomapPanel | undefined = undefined;
|
||||
|
||||
type Props = PanelProps<GeomapPanelOptions>;
|
||||
export class GeomapPanel extends Component<Props> {
|
||||
interface State extends OverlayProps {}
|
||||
export class GeomapPanel extends Component<Props, State> {
|
||||
globalCSS = getGlobalStyles(config.theme2);
|
||||
|
||||
counter = 0;
|
||||
map?: Map;
|
||||
basemap?: BaseLayer;
|
||||
layers: MapLayerState[] = [];
|
||||
mouseWheelZoom?: MouseWheelZoom;
|
||||
style = getStyles(config.theme);
|
||||
overlayProps: OverlayProps = {};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
componentDidMount() {
|
||||
lastGeomapPanelInstance = this;
|
||||
}
|
||||
@ -65,7 +69,7 @@ export class GeomapPanel extends Component<Props> {
|
||||
|
||||
// External data changed
|
||||
if (layersChanged || this.props.data !== nextProps.data) {
|
||||
this.dataChanged(nextProps.data, nextProps.options.controls.showLegend);
|
||||
this.dataChanged(nextProps.data);
|
||||
}
|
||||
|
||||
return true; // always?
|
||||
@ -106,17 +110,12 @@ export class GeomapPanel extends Component<Props> {
|
||||
/**
|
||||
* Called when PanelData changes (query results etc)
|
||||
*/
|
||||
dataChanged(data: PanelData, showLegend?: boolean) {
|
||||
const legends: React.ReactNode[] = [];
|
||||
dataChanged(data: PanelData) {
|
||||
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 = async (div: HTMLDivElement) => {
|
||||
@ -143,7 +142,7 @@ export class GeomapPanel extends Component<Props> {
|
||||
this.map.addInteraction(this.mouseWheelZoom);
|
||||
this.initControls(options.controls);
|
||||
this.initBasemap(options.basemap);
|
||||
await this.initLayers(options.layers, options.controls?.showLegend);
|
||||
await this.initLayers(options.layers);
|
||||
this.forceUpdate(); // first render
|
||||
};
|
||||
|
||||
@ -166,7 +165,7 @@ export class GeomapPanel extends Component<Props> {
|
||||
this.map.getLayers().insertAt(0, this.basemap);
|
||||
}
|
||||
|
||||
async initLayers(layers: MapLayerOptions[], showLegend?: boolean) {
|
||||
async initLayers(layers: MapLayerOptions[]) {
|
||||
// 1st remove existing layers
|
||||
for (const state of this.layers) {
|
||||
this.map!.removeLayer(state.layer);
|
||||
@ -177,6 +176,7 @@ export class GeomapPanel extends Component<Props> {
|
||||
layers = [];
|
||||
}
|
||||
|
||||
const legends: React.ReactNode[] = [];
|
||||
this.layers = [];
|
||||
for (const overlay of layers) {
|
||||
const item = geomapLayerRegistry.getIfExists(overlay.type);
|
||||
@ -193,7 +193,12 @@ export class GeomapPanel extends Component<Props> {
|
||||
layer,
|
||||
handler,
|
||||
});
|
||||
|
||||
if (handler.legend) {
|
||||
legends.push(<div key={`${this.counter++}`}>{handler.legend}</div>);
|
||||
}
|
||||
}
|
||||
this.setState({ bottomLeft: legends });
|
||||
|
||||
// Update data after init layers
|
||||
this.dataChanged(this.props.data);
|
||||
@ -270,12 +275,12 @@ export class GeomapPanel extends Component<Props> {
|
||||
}
|
||||
|
||||
// Update the react overlays
|
||||
const overlayProps: OverlayProps = {};
|
||||
let topRight: ReactNode[] = [];
|
||||
if (options.showDebug) {
|
||||
overlayProps.topRight = [<DebugOverlay key="debug" map={this.map} />];
|
||||
topRight = [<DebugOverlay key="debug" map={this.map} />];
|
||||
}
|
||||
|
||||
this.overlayProps = overlayProps;
|
||||
this.setState({ topRight });
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -284,7 +289,7 @@ export class GeomapPanel extends Component<Props> {
|
||||
<Global styles={this.globalCSS} />
|
||||
<div className={this.style.wrap}>
|
||||
<div className={this.style.map} ref={this.initMapRef}></div>
|
||||
<GeomapOverlay {...this.overlayProps} />
|
||||
<GeomapOverlay {...this.state} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -0,0 +1,52 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Observable, Unsubscribable } from 'rxjs';
|
||||
|
||||
interface Props<T> {
|
||||
watch: Observable<T>;
|
||||
child: React.ComponentType<T>;
|
||||
initialSubProps: T;
|
||||
}
|
||||
|
||||
interface State<T> {
|
||||
subProps: T;
|
||||
}
|
||||
|
||||
export class ObservablePropsWrapper<T> extends Component<Props<T>, State<T>> {
|
||||
sub?: Unsubscribable;
|
||||
|
||||
constructor(props: Props<T>) {
|
||||
super(props);
|
||||
this.state = {
|
||||
subProps: props.initialSubProps,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
console.log('ObservablePropsWrapper:subscribe');
|
||||
this.sub = this.props.watch.subscribe({
|
||||
next: (subProps: T) => {
|
||||
console.log('ObservablePropsWrapper:NEXT', subProps);
|
||||
this.setState({ subProps });
|
||||
},
|
||||
complete: () => {
|
||||
console.log('ObservablePropsWrapper:complete');
|
||||
},
|
||||
error: (err) => {
|
||||
console.log('ObservablePropsWrapper:error', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.sub) {
|
||||
this.sub.unsubscribe();
|
||||
}
|
||||
console.log('ObservablePropsWrapper:unsubscribe');
|
||||
}
|
||||
|
||||
render() {
|
||||
const { subProps } = this.state;
|
||||
console.log('RENDER (wrap)', subProps);
|
||||
return <this.props.child {...subProps} />;
|
||||
}
|
||||
}
|
@ -1,96 +0,0 @@
|
||||
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>< {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;
|
||||
`,
|
||||
}));
|
@ -93,6 +93,7 @@ export const ScaleDimensionEditor: FC<StandardEditorProps<ScaleDimensionConfig,
|
||||
[validateAndDoChange, value]
|
||||
);
|
||||
|
||||
const val = value ?? {};
|
||||
const selectedOption = isFixed ? fixedValueOption : selectOptions.find((v) => v.value === fieldName);
|
||||
return (
|
||||
<>
|
||||
@ -108,7 +109,7 @@ export const ScaleDimensionEditor: FC<StandardEditorProps<ScaleDimensionConfig,
|
||||
{isFixed && (
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Value" labelWidth={8} grow={true}>
|
||||
<NumberInput value={value.fixed} {...minMaxStep} onChange={onValueChange} />
|
||||
<NumberInput value={val.fixed} {...minMaxStep} onChange={onValueChange} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
@ -116,12 +117,12 @@ export const ScaleDimensionEditor: FC<StandardEditorProps<ScaleDimensionConfig,
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Min" labelWidth={8} grow={true}>
|
||||
<NumberInput value={value.min} {...minMaxStep} onChange={onMinChange} />
|
||||
<NumberInput value={val.min} {...minMaxStep} onChange={onMinChange} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Max" labelWidth={8} grow={true}>
|
||||
<NumberInput value={value.max} {...minMaxStep} onChange={onMaxChange} />
|
||||
<NumberInput value={val.max} {...minMaxStep} onChange={onMaxChange} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
</>
|
||||
|
113
public/app/plugins/panel/geomap/layers/data/MarkersLegend.tsx
Normal file
113
public/app/plugins/panel/geomap/layers/data/MarkersLegend.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { Label, stylesFactory } from '@grafana/ui';
|
||||
import { formattedValueToString, getFieldColorModeForField, GrafanaTheme } from '@grafana/data';
|
||||
import { css } from '@emotion/css';
|
||||
import { config } from 'app/core/config';
|
||||
import { DimensionSupplier } from '../../dims/types';
|
||||
import { getMinMaxAndDelta } from '../../../../../../../packages/grafana-data/src/field/scale';
|
||||
|
||||
export interface MarkersLegendProps {
|
||||
color?: DimensionSupplier<string>;
|
||||
size?: DimensionSupplier<number>;
|
||||
}
|
||||
export function MarkersLegend(props: MarkersLegendProps) {
|
||||
const { color } = props;
|
||||
if (!color || (!color.field && color.fixed)) {
|
||||
return (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
const style = getStyles(config.theme);
|
||||
|
||||
const fmt = (v: any) => `${formattedValueToString(color.field!.display!(v))}`;
|
||||
const colorMode = getFieldColorModeForField(color!.field!);
|
||||
|
||||
if (colorMode.isContinuous && colorMode.getColors) {
|
||||
const colors = colorMode.getColors(config.theme2)
|
||||
const colorRange = getMinMaxAndDelta(color.field!)
|
||||
// TODO: explore showing mean on the gradiant scale
|
||||
// const stats = reduceField({
|
||||
// field: color.field!,
|
||||
// reducers: [
|
||||
// ReducerID.min,
|
||||
// ReducerID.max,
|
||||
// ReducerID.mean,
|
||||
// // std dev?
|
||||
// ]
|
||||
// })
|
||||
|
||||
return <>
|
||||
<Label>{color?.field?.name}</Label>
|
||||
<div className={style.gradientContainer} style={{backgroundImage: `linear-gradient(to right, ${colors.map((c) => c).join(', ')}`}}>
|
||||
<div>{fmt(colorRange.min)}</div>
|
||||
<div>{fmt(colorRange.max)}</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
const thresholds = color.field?.config?.thresholds;
|
||||
if (!thresholds) {
|
||||
return <div className={style.infoWrap}>no thresholds????</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.infoWrap}>
|
||||
{thresholds && (
|
||||
<div className={style.legend}>
|
||||
{thresholds.steps.map((step:any, idx:number) => {
|
||||
const next = thresholds!.steps[idx + 1];
|
||||
let info = <span>?</span>;
|
||||
if (idx === 0) {
|
||||
info = <span>< {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={style.legendItem}>
|
||||
<i style={{ background: config.theme2.visualization.getColorByName(step.color) }}></i>
|
||||
{info}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
infoWrap: css`
|
||||
color: #999;
|
||||
background: #CCCC;
|
||||
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;
|
||||
`,
|
||||
gradientContainer: css`
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`
|
||||
}));
|
@ -11,6 +11,11 @@ import { getScaledDimension, } from '../../dims/scale';
|
||||
import { getColorDimension, } from '../../dims/color';
|
||||
import { ScaleDimensionEditor } from '../../dims/editors/ScaleDimensionEditor';
|
||||
import { ColorDimensionEditor } from '../../dims/editors/ColorDimensionEditor';
|
||||
import React from 'react';
|
||||
import { ObservablePropsWrapper } from '../../components/ObservablePropsWrapper';
|
||||
import { ReplaySubject } from 'rxjs';
|
||||
import { MarkersLegend, MarkersLegendProps } from './MarkersLegend';
|
||||
import { ReactNode } from 'react';
|
||||
import { circleMarker, markerMakers } from '../../utils/regularShapes';
|
||||
|
||||
// Configuration options for Circle overlays
|
||||
@ -19,6 +24,7 @@ export interface MarkersConfig {
|
||||
color: ColorDimensionConfig;
|
||||
fillOpacity: number;
|
||||
shape?: string;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
|
||||
const defaultOptions: MarkersConfig = {
|
||||
@ -32,6 +38,7 @@ const defaultOptions: MarkersConfig = {
|
||||
},
|
||||
fillOpacity: 0.4,
|
||||
shape: 'circle',
|
||||
showLegend: true,
|
||||
};
|
||||
|
||||
export const MARKERS_LAYER_ID = "markers";
|
||||
@ -68,10 +75,21 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
...options?.config,
|
||||
};
|
||||
|
||||
const legendProps= new ReplaySubject<MarkersLegendProps>(1);
|
||||
let legend:ReactNode = null;
|
||||
if (config.showLegend) {
|
||||
legend = <ObservablePropsWrapper
|
||||
watch={legendProps}
|
||||
initialSubProps={{}}
|
||||
child={MarkersLegend}
|
||||
/>
|
||||
}
|
||||
const shape = markerMakers.getIfExists(config.shape) ?? circleMarker;
|
||||
console.log( 'CREATE Marker layer', matchers);
|
||||
|
||||
return {
|
||||
init: () => vectorLayer,
|
||||
legend: legend,
|
||||
update: (data: PanelData) => {
|
||||
if(!data.series?.length) {
|
||||
return; // ignore empty
|
||||
@ -107,8 +125,17 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
dot.setStyle(shape!.make(color, fillColor, radius));
|
||||
features.push(dot);
|
||||
};
|
||||
}
|
||||
|
||||
// Post updates to the legend component
|
||||
if (legend) {
|
||||
console.log( 'UPDATE (marker layer)', colorDim);
|
||||
legendProps.next({
|
||||
color: colorDim,
|
||||
size: sizeDim,
|
||||
});
|
||||
}
|
||||
break; // Only the first frame for now!
|
||||
}
|
||||
|
||||
// Source reads the data and provides a set of features to visualize
|
||||
const vectorSource = new source.Vector({ features });
|
||||
@ -162,6 +189,12 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
step: 0.1,
|
||||
},
|
||||
showIf: (cfg) => (markerMakers.getIfExists((cfg as any).config?.shape)?.hasFill),
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'config.showLegend',
|
||||
name: 'Show legend',
|
||||
description: 'Show legend',
|
||||
defaultValue: defaultOptions.showLegend,
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -52,7 +52,6 @@ describe('Worldmap Migrations', () => {
|
||||
},
|
||||
"controls": Object {
|
||||
"mouseWheelZoom": true,
|
||||
"showLegend": true,
|
||||
"showZoom": true,
|
||||
},
|
||||
"layers": Array [],
|
||||
|
@ -31,7 +31,6 @@ export function worldmapToGeomapOptions(angular: any): { fieldConfig: FieldConfi
|
||||
},
|
||||
controls: {
|
||||
showZoom: true,
|
||||
showLegend: Boolean(angular.showLegend),
|
||||
mouseWheelZoom: Boolean(angular.mouseWheelZoom),
|
||||
},
|
||||
basemap: {
|
||||
|
@ -66,13 +66,6 @@ export const plugin = new PanelPlugin<GeomapPanelOptions>(GeomapPanel)
|
||||
name: 'Mouse wheel zoom',
|
||||
defaultValue: true,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
category,
|
||||
path: 'controls.showLegend',
|
||||
name: 'Show legend',
|
||||
description: 'Show legend',
|
||||
defaultValue: true,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
category,
|
||||
path: 'controls.showAttribution',
|
||||
|
@ -9,9 +9,6 @@ export interface ControlsOptions {
|
||||
// let the mouse wheel zoom
|
||||
mouseWheelZoom?: boolean;
|
||||
|
||||
// Add legend control
|
||||
showLegend?: boolean;
|
||||
|
||||
// Lower right
|
||||
showAttribution?: boolean;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user