Geomap: Add network layer (#70192)

* Geomap: Add network layer

* Support text labels for nodes

* Add solid styling for edges

* Remove symbol option for edge style menu

* Add support for edge text labels

* Fix linter issues

* Simplify multiple data frame handling

* Add TODO notes

* Add node and edge style categories for options

* Remove data frame hardcoding

* Hide legend, attempt to hide tooltip by default

* Mark network layer as beta

* refactor updateEdge

* Fix some linter issues

* Remove attempt at disabling tooltip for network layer

* For edge text add a stroke and increase z index

* Restrict field selection based on frame type

* refactor

* add basic bad data handling (prevent entire panel from breaking)

* generate non hard coded graph frames for style editor filtering

* code cleanup; remove hardcoded reference to "edges" frame

* fix select clearing for Data option

* fix styling

* fix lookup

---------

Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
grobinson/calc-from-duration
Drew Slobodnjak 2 years ago committed by GitHub
parent 982624cf51
commit 59bed9e156
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .betterer.results
  2. 6
      packages/grafana-data/src/geo/layer.ts
  3. 2
      packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx
  4. 2
      public/app/features/geo/utils/frameVectorSource.ts
  5. 55
      public/app/plugins/panel/geomap/editor/StyleEditor.tsx
  6. 2
      public/app/plugins/panel/geomap/editor/layerEditor.tsx
  7. 18
      public/app/plugins/panel/geomap/layers/data/index.ts
  8. 364
      public/app/plugins/panel/geomap/layers/data/networkLayer.tsx
  9. 7
      public/app/plugins/panel/nodeGraph/types.ts
  10. 18
      public/app/plugins/panel/nodeGraph/useCategorizeFrames.ts
  11. 17
      public/app/plugins/panel/nodeGraph/utils.ts

@ -208,7 +208,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "7"]
],
"packages/grafana-data/src/geo/layer.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"packages/grafana-data/src/panel/PanelPlugin.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],

@ -5,6 +5,7 @@ import { ReactNode } from 'react';
import { MapLayerOptions, FrameGeometrySourceMode } from '@grafana/schema';
import { EventBus } from '../events';
import { StandardEditorContext } from '../field/standardFieldConfigEditorRegistry';
import { GrafanaTheme2 } from '../themes';
import { PanelData } from '../types';
import { PanelOptionsEditorBuilder } from '../utils';
@ -39,7 +40,10 @@ export interface MapLayerHandler<TConfig = any> {
/**
* Show custom elements in the panel edit UI
*/
registerOptionsUI?: (builder: PanelOptionsEditorBuilder<MapLayerOptions<TConfig>>) => void;
registerOptionsUI?: (
builder: PanelOptionsEditorBuilder<MapLayerOptions<TConfig>>,
context: StandardEditorContext<any, any>
) => void;
}
/**

@ -56,7 +56,7 @@ export function RefIDPicker({ value, data, onChange, placeholder }: Props) {
const onFilterChange = useCallback(
(v: SelectableValue<string>) => {
onChange(v.value!);
onChange(v?.value!);
},
[onChange]
);

@ -9,7 +9,7 @@ import { getGeometryField, LocationFieldMatchers } from './location';
export interface FrameVectorSourceOptions {}
export class FrameVectorSource<T extends Geometry = Geometry> extends VectorSource<T> {
constructor(private location: LocationFieldMatchers) {
constructor(public location: LocationFieldMatchers) {
super({});
}

@ -1,9 +1,9 @@
import { capitalize } from 'lodash';
import React from 'react';
import React, { useMemo } from 'react';
import { useObservable } from 'react-use';
import { Observable, of } from 'rxjs';
import { FieldConfigPropertyItem, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
import { FieldConfigPropertyItem, StandardEditorProps, StandardEditorsRegistryItem, FrameMatcher } from '@grafana/data';
import {
ScaleDimensionConfig,
ResourceDimensionConfig,
@ -39,11 +39,22 @@ export interface StyleEditorOptions {
layerInfo?: Observable<LayerContentInfo>;
simpleFixedValues?: boolean;
displayRotation?: boolean;
hideSymbol?: boolean;
frameMatcher?: FrameMatcher;
}
type Props = StandardEditorProps<StyleConfig, StyleEditorOptions>;
export const StyleEditor = ({ value, context, onChange, item }: Props) => {
export const StyleEditor = (props: Props) => {
const { value, onChange, item } = props;
const context = useMemo(() => {
if (!item.settings?.frameMatcher) {
return props.context;
}
return { ...props.context, data: props.context.data.filter(item.settings.frameMatcher) };
}, [props.context, item.settings]);
const settings = item.settings;
const onSizeChange = (sizeValue: ScaleDimensionConfig | undefined) => {
@ -188,24 +199,26 @@ export const StyleEditor = ({ value, context, onChange, item }: Props) => {
}
/>
</Field>
<Field label={'Symbol'}>
<ResourceDimensionEditor
value={value?.symbol ?? defaultStyleConfig.symbol}
context={context}
onChange={onSymbolChange}
item={
{
settings: {
resourceType: MediaType.Icon,
folderName: ResourceFolderName.Marker,
placeholderText: hasTextLabel ? 'Select a symbol' : 'Select a symbol or add a text label',
placeholderValue: defaultStyleConfig.symbol.fixed,
showSourceRadio: false,
},
} as StandardEditorsRegistryItem
}
/>
</Field>
{!settings?.hideSymbol && (
<Field label={'Symbol'}>
<ResourceDimensionEditor
value={value?.symbol ?? defaultStyleConfig.symbol}
context={context}
onChange={onSymbolChange}
item={
{
settings: {
resourceType: MediaType.Icon,
folderName: ResourceFolderName.Marker,
placeholderText: hasTextLabel ? 'Select a symbol' : 'Select a symbol or add a text label',
placeholderValue: defaultStyleConfig.symbol.fixed,
showSourceRadio: false,
},
} as StandardEditorsRegistryItem
}
/>
</Field>
)}
<Field label={'Color'}>
<ColorDimensionEditor
value={value?.color ?? defaultStyleConfig.color}

@ -106,7 +106,7 @@ export function getLayerEditor(opts: LayerEditorOptions): NestedPanelOptions<Map
addLocationFields('Location', 'location.', builder, options.location, data);
}
if (handler.registerOptionsUI) {
handler.registerOptionsUI(builder);
handler.registerOptionsUI(builder, context);
}
if (!isEqual(opts.category, ['Base layer'])) {
if (!layer.hideOpacity) {

@ -4,6 +4,7 @@ import { geojsonLayer } from './geojsonLayer';
import { heatmapLayer } from './heatMap';
import { lastPointTracker } from './lastPointTracker';
import { markersLayer } from './markersLayer';
import { networkLayer } from './networkLayer';
import { photosLayer } from './photosLayer';
import { routeLayer } from './routeLayer';
@ -11,12 +12,13 @@ import { routeLayer } from './routeLayer';
* Registry for layer handlers
*/
export const dataLayers = [
markersLayer,
heatmapLayer,
lastPointTracker,
geojsonLayer,
dynamicGeoJSONLayer,
dayNightLayer,
routeLayer,
photosLayer
markersLayer,
heatmapLayer,
lastPointTracker,
geojsonLayer,
dynamicGeoJSONLayer,
dayNightLayer,
routeLayer,
photosLayer,
networkLayer,
];

@ -0,0 +1,364 @@
import { isNumber } from 'lodash';
import { Feature } from 'ol';
import { FeatureLike } from 'ol/Feature';
import Map from 'ol/Map';
import { Geometry, LineString, Point, SimpleGeometry } from 'ol/geom';
import VectorLayer from 'ol/layer/Vector';
import { Fill, Stroke, Style, Text } from 'ol/style';
import FlowLine from 'ol-ext/style/FlowLine';
import React, { ReactNode } from 'react';
import { ReplaySubject } from 'rxjs';
import tinycolor from 'tinycolor2';
import {
MapLayerRegistryItem,
MapLayerOptions,
PanelData,
GrafanaTheme2,
FrameGeometrySourceMode,
EventBus,
DataFrame,
Field,
PluginState,
} from '@grafana/data';
import { FrameVectorSource } from 'app/features/geo/utils/frameVectorSource';
import { getGeometryField, getLocationMatchers } from 'app/features/geo/utils/location';
import { GraphFrame } from 'app/plugins/panel/nodeGraph/types';
import { getGraphFrame } from 'app/plugins/panel/nodeGraph/utils';
import { MarkersLegendProps, MarkersLegend } from '../../components/MarkersLegend';
import { ObservablePropsWrapper } from '../../components/ObservablePropsWrapper';
import { StyleEditor } from '../../editor/StyleEditor';
import { StyleConfig, defaultStyleConfig } from '../../style/types';
import { getStyleConfigState } from '../../style/utils';
import { getStyleDimension } from '../../utils/utils';
export interface NetworkConfig {
style: StyleConfig;
showLegend?: boolean;
edgeStyle: StyleConfig;
arrow?: 0 | 1 | -1 | 2;
}
const defaultOptions: NetworkConfig = {
style: defaultStyleConfig,
showLegend: false,
edgeStyle: defaultStyleConfig,
arrow: 0,
};
export const NETWORK_LAYER_ID = 'network';
// Used by default when nothing is configured
export const defaultMarkersConfig: MapLayerOptions<NetworkConfig> = {
type: NETWORK_LAYER_ID,
name: '', // will get replaced
config: defaultOptions,
location: {
mode: FrameGeometrySourceMode.Auto,
},
};
/**
* Map layer configuration for network overlay
*/
export const networkLayer: MapLayerRegistryItem<NetworkConfig> = {
id: NETWORK_LAYER_ID,
name: 'Network',
description: 'Render a node graph as a map layer',
isBaseMap: false,
showLocation: true,
hideOpacity: true,
state: PluginState.beta,
/**
* Function that configures transformation and returns a transformer
* @param map
* @param options
* @param eventBus
* @param theme
*/
create: async (map: Map, options: MapLayerOptions<NetworkConfig>, eventBus: EventBus, theme: GrafanaTheme2) => {
// Assert default values
const config = {
...defaultOptions,
...options?.config,
};
const style = await getStyleConfigState(config.style);
const edgeStyle = await getStyleConfigState(config.edgeStyle);
const location = await getLocationMatchers(options.location);
const source = new FrameVectorSource(location);
const vectorLayer = new VectorLayer({
source,
});
const hasArrows = config.arrow === 1 || config.arrow === -1 || config.arrow === 2;
// TODO update legend to display edges as well
const legendProps = new ReplaySubject<MarkersLegendProps>(1);
let legend: ReactNode = null;
if (config.showLegend) {
legend = <ObservablePropsWrapper watch={legendProps} initialSubProps={{}} child={MarkersLegend} />;
}
vectorLayer.setStyle((feature: FeatureLike) => {
const geom = feature.getGeometry();
const idx = feature.get('rowIndex');
const dims = style.dims;
if (!style.fields && !edgeStyle.fields && !hasArrows && geom?.getType() !== 'LineString') {
// Set a global style
return style.maker(style.base);
}
// For edges
if (geom?.getType() === 'LineString' && geom instanceof SimpleGeometry) {
const edgeDims = edgeStyle.dims;
const edgeTextConfig = edgeStyle.config.textConfig;
const edgeId = Number(feature.getId());
const coordinates = geom.getCoordinates();
const opacity = edgeStyle.config.opacity ?? 1;
if (coordinates && edgeDims) {
const segmentStartCoords = coordinates[0];
const segmentEndCoords = coordinates[1];
const color1 = tinycolor(
theme.visualization.getColorByName((edgeDims.color && edgeDims.color.get(edgeId)) ?? edgeStyle.base.color)
)
.setAlpha(opacity)
.toString();
const color2 = tinycolor(
theme.visualization.getColorByName((edgeDims.color && edgeDims.color.get(edgeId)) ?? edgeStyle.base.color)
)
.setAlpha(opacity)
.toString();
const arrowSize1 = (edgeDims.size && edgeDims.size.get(edgeId)) ?? edgeStyle.base.size;
const arrowSize2 = (edgeDims.size && edgeDims.size.get(edgeId)) ?? edgeStyle.base.size;
const styles = [];
const flowStyle = new FlowLine({
visible: true,
lineCap: config.arrow === 0 ? 'round' : 'square',
color: color1,
color2: color2,
width: (edgeDims.size && edgeDims.size.get(edgeId)) ?? edgeStyle.base.size,
width2: (edgeDims.size && edgeDims.size.get(edgeId)) ?? edgeStyle.base.size,
});
if (config.arrow) {
flowStyle.setArrow(config.arrow);
if (config.arrow > 0) {
flowStyle.setArrowColor(color2);
flowStyle.setArrowSize((arrowSize2 ?? 0) * 2);
} else {
flowStyle.setArrowColor(color1);
flowStyle.setArrowSize((arrowSize1 ?? 0) * 2);
}
}
const LS = new LineString([segmentStartCoords, segmentEndCoords]);
flowStyle.setGeometry(LS);
const fontFamily = theme.typography.fontFamily;
if (edgeDims.text) {
const labelStyle = new Style({
zIndex: 10,
text: new Text({
text: edgeDims.text.get(edgeId),
font: `normal ${edgeTextConfig?.fontSize}px ${fontFamily}`,
fill: new Fill({ color: color1 ?? defaultStyleConfig.color.fixed }),
stroke: new Stroke({
color: tinycolor(theme.visualization.getColorByName('text')).setAlpha(opacity).toString(),
width: Math.max(edgeTextConfig?.fontSize! / 10, 1),
}),
...edgeTextConfig,
}),
});
labelStyle.setGeometry(LS);
styles.push(labelStyle);
}
styles.push(flowStyle);
return styles;
}
}
if (!dims || !isNumber(idx)) {
return style.maker(style.base);
}
const values = { ...style.base };
if (dims.color) {
values.color = dims.color.get(idx);
}
if (dims.size) {
values.size = dims.size.get(idx);
}
if (dims.text) {
values.text = dims.text.get(idx);
}
if (dims.rotation) {
values.rotation = dims.rotation.get(idx);
}
return style.maker(values);
});
return {
init: () => vectorLayer,
legend: legend,
update: (data: PanelData) => {
if (!data.series?.length) {
source.clear();
return; // ignore empty
}
// Post updates to the legend component
if (legend) {
legendProps.next({
styleConfig: style,
size: style.dims?.size,
layerName: options.name,
layer: vectorLayer,
});
}
const graphFrames = getGraphFrame(data.series);
for (const frame of data.series) {
if (frame === graphFrames.edges[0]) {
edgeStyle.dims = getStyleDimension(frame, edgeStyle, theme);
} else {
style.dims = getStyleDimension(frame, style, theme);
}
updateEdge(source, graphFrames);
}
},
// Marker overlay options
registerOptionsUI: (builder, context) => {
const networkFrames = getGraphFrame(context.data);
const frameNodes = networkFrames.nodes[0];
const frameEdges = networkFrames.edges[0];
builder
.addCustomEditor({
id: 'config.style',
category: ['Node Styles'],
path: 'config.style',
name: 'Node Styles',
editor: StyleEditor,
settings: {
displayRotation: true,
frameMatcher: (frame: DataFrame) => frame === frameNodes,
},
defaultValue: defaultOptions.style,
})
.addCustomEditor({
id: 'config.edgeStyle',
category: ['Edge Styles'],
path: 'config.edgeStyle',
name: 'Edge Styles',
editor: StyleEditor,
settings: {
hideSymbol: true,
frameMatcher: (frame: DataFrame) => frame === frameEdges,
},
defaultValue: defaultOptions.style,
})
.addRadio({
path: 'config.arrow',
name: 'Arrow',
settings: {
options: [
{ label: 'None', value: 0 },
{ label: 'Forward', value: 1 },
{ label: 'Reverse', value: -1 },
{ label: 'Both', value: 2 },
],
},
defaultValue: defaultOptions.arrow,
})
.addBooleanSwitch({
path: 'config.showLegend',
name: 'Show legend',
description: 'Show map legend',
defaultValue: defaultOptions.showLegend,
});
},
};
},
// fill in the default values
defaultOptions,
};
function updateEdge(source: FrameVectorSource, graphFrames: GraphFrame) {
source.clear(true);
const frameNodes = graphFrames.nodes[0];
const frameEdges = graphFrames.edges[0];
if (!frameNodes || !frameEdges) {
// TODO: provide helpful error message / link to docs for how to format data
return;
}
const info = getGeometryField(frameNodes, source.location);
if (!info.field) {
source.changed();
return;
}
// TODO: Fix this
// eslint-disable-next-line
const field = info.field as unknown as Field<Point>;
// TODO for nodes, don't hard code id field name
const nodeIdIndex = frameNodes.fields.findIndex((f: Field) => f.name === 'id');
const nodeIdValues = frameNodes.fields[nodeIdIndex].values;
// Edges
// TODO for edges, don't hard code source and target fields
const sourceIndex = frameEdges.fields.findIndex((f: Field) => f.name === 'source');
const targetIndex = frameEdges.fields.findIndex((f: Field) => f.name === 'target');
const sources = frameEdges.fields[sourceIndex].values;
const targets = frameEdges.fields[targetIndex].values;
// Loop through edges, referencing node locations
for (let i = 0; i < sources.length; i++) {
// Create linestring for each edge
const sourceId = sources[i];
const targetId = targets[i];
const sourceNodeIndex = nodeIdValues.findIndex((value: string) => value === sourceId);
const targetNodeIndex = nodeIdValues.findIndex((value: string) => value === targetId);
if (!field.values[sourceNodeIndex] || !field.values[targetNodeIndex]) {
continue;
}
const geometryEdge: Geometry = new LineString([
field.values[sourceNodeIndex].getCoordinates(),
field.values[targetNodeIndex].getCoordinates(),
]);
const edgeFeature = new Feature({
geometry: geometryEdge,
});
edgeFeature.setId(i);
source['addFeatureInternal'](edgeFeature); // @TODO revisit?
}
// Nodes
for (let i = 0; i < frameNodes.length; i++) {
source['addFeatureInternal'](
new Feature({
frameNodes,
rowIndex: i,
geometry: info.field.values[i],
})
);
}
// only call source at the end
source.changed();
}

@ -1,6 +1,6 @@
import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
import { Field, IconName } from '@grafana/data';
import { DataFrame, Field, IconName } from '@grafana/data';
export { Options as NodeGraphOptions, ArcOption } from './panelcfg.gen';
@ -43,3 +43,8 @@ export type NodesMarker = {
node: NodeDatum;
count: number;
};
export type GraphFrame = {
nodes: DataFrame[];
edges: DataFrame[];
};

@ -2,6 +2,8 @@ import { useMemo } from 'react';
import { DataFrame } from '@grafana/data';
import { getGraphFrame } from './utils';
/**
* As we need 2 dataframes for the service map, one with nodes and one with edges we have to figure out which is which.
* Right now we do not have any metadata for it so we just check preferredVisualisationType and then column names.
@ -9,20 +11,6 @@ import { DataFrame } from '@grafana/data';
*/
export function useCategorizeFrames(series: DataFrame[]) {
return useMemo(() => {
return series.reduce<{
nodes: DataFrame[];
edges: DataFrame[];
}>(
(acc, frame) => {
const sourceField = frame.fields.filter((f) => f.name === 'source');
if (sourceField.length) {
acc.edges.push(frame);
} else {
acc.nodes.push(frame);
}
return acc;
},
{ edges: [], nodes: [] }
);
return getGraphFrame(series);
}, [series]);
}

@ -9,7 +9,7 @@ import {
NodeGraphDataFrameFieldNames,
} from '@grafana/data';
import { EdgeDatum, NodeDatum, NodeDatumFromEdge, NodeGraphOptions } from './types';
import { EdgeDatum, GraphFrame, NodeDatum, NodeDatumFromEdge, NodeGraphOptions } from './types';
type Line = { x1: number; y1: number; x2: number; y2: number };
@ -593,3 +593,18 @@ export const findConnectedNodesForNode = (nodes: NodeDatum[], edges: EdgeDatum[]
}
return [];
};
export const getGraphFrame = (frames: DataFrame[]) => {
return frames.reduce<GraphFrame>(
(acc, frame) => {
const sourceField = frame.fields.filter((f) => f.name === 'source');
if (sourceField.length) {
acc.edges.push(frame);
} else {
acc.nodes.push(frame);
}
return acc;
},
{ edges: [], nodes: [] }
);
};

Loading…
Cancel
Save