Geomap: configure legend on map (#37077)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
An 2021-07-26 18:50:15 -04:00 committed by GitHub
parent 5f0bc252bc
commit 154c380c8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 226 additions and 130 deletions

View File

@ -68,8 +68,8 @@ export interface MapLayerOptions<TConfig = any> {
*/
export interface MapLayerHandler {
init: () => BaseLayer;
legend?: () => ReactNode;
update?: (data: PanelData) => void;
legend?: ReactNode;
}
/**

View File

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

View File

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

View File

@ -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>&lt; {fmt(next.value)}</span>;
} else if (next) {
info = (
<span>
{fmt(step.value)} - {fmt(next.value)}
</span>
);
} else {
info = <span>{fmt(step.value)} +</span>;
}
return (
<div key={`${idx}/${step.value}`} className={this.style.legendItem}>
<i style={{ background: config.theme2.visualization.getColorByName(step.color) }}></i>
{info}
</div>
);
})}
</div>
)}
</div>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
infoWrap: css`
color: ${theme.colors.text};
background: ${tinycolor(theme.colors.panelBg).setAlpha(0.7).toString()};
border-radius: 2px;
padding: 8px;
`,
legend: css`
line-height: 18px;
color: #555;
display: flex;
flex-direction: column;
i {
width: 18px;
height: 18px;
float: left;
margin-right: 8px;
opacity: 0.7;
}
`,
legendItem: css`
white-space: nowrap;
`,
}));

View File

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

View 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>&lt; {fmt(next.value)}</span>;
} else if (next) {
info = (
<span>
{fmt(step.value)} - {fmt(next.value)}
</span>
);
} else {
info = <span>{fmt(step.value)} +</span>;
}
return (
<div key={`${idx}/${step.value}`} className={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;
`
}));

View File

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

View File

@ -52,7 +52,6 @@ describe('Worldmap Migrations', () => {
},
"controls": Object {
"mouseWheelZoom": true,
"showLegend": true,
"showZoom": true,
},
"layers": Array [],

View File

@ -31,7 +31,6 @@ export function worldmapToGeomapOptions(angular: any): { fieldConfig: FieldConfi
},
controls: {
showZoom: true,
showLegend: Boolean(angular.showLegend),
mouseWheelZoom: Boolean(angular.mouseWheelZoom),
},
basemap: {

View File

@ -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',

View File

@ -9,9 +9,6 @@ export interface ControlsOptions {
// let the mouse wheel zoom
mouseWheelZoom?: boolean;
// Add legend control
showLegend?: boolean;
// Lower right
showAttribution?: boolean;