diff --git a/.betterer.results b/.betterer.results index 1b66f1cfdac..d186fb188f7 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/packages/grafana-data/src/geo/layer.ts b/packages/grafana-data/src/geo/layer.ts index 4ad67866fbb..c51750c3600 100644 --- a/packages/grafana-data/src/geo/layer.ts +++ b/packages/grafana-data/src/geo/layer.ts @@ -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 { /** * Show custom elements in the panel edit UI */ - registerOptionsUI?: (builder: PanelOptionsEditorBuilder>) => void; + registerOptionsUI?: ( + builder: PanelOptionsEditorBuilder>, + context: StandardEditorContext + ) => void; } /** diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx index 65617bfacb4..05cf8d84bbd 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx @@ -56,7 +56,7 @@ export function RefIDPicker({ value, data, onChange, placeholder }: Props) { const onFilterChange = useCallback( (v: SelectableValue) => { - onChange(v.value!); + onChange(v?.value!); }, [onChange] ); diff --git a/public/app/features/geo/utils/frameVectorSource.ts b/public/app/features/geo/utils/frameVectorSource.ts index 8f85f420af5..25cf679a44c 100644 --- a/public/app/features/geo/utils/frameVectorSource.ts +++ b/public/app/features/geo/utils/frameVectorSource.ts @@ -9,7 +9,7 @@ import { getGeometryField, LocationFieldMatchers } from './location'; export interface FrameVectorSourceOptions {} export class FrameVectorSource extends VectorSource { - constructor(private location: LocationFieldMatchers) { + constructor(public location: LocationFieldMatchers) { super({}); } diff --git a/public/app/plugins/panel/geomap/editor/StyleEditor.tsx b/public/app/plugins/panel/geomap/editor/StyleEditor.tsx index ee6e369ef79..56aba8718aa 100644 --- a/public/app/plugins/panel/geomap/editor/StyleEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/StyleEditor.tsx @@ -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; simpleFixedValues?: boolean; displayRotation?: boolean; + hideSymbol?: boolean; + frameMatcher?: FrameMatcher; } type Props = StandardEditorProps; -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) => { } /> - - - + {!settings?.hideSymbol && ( + + + + )} = { + type: NETWORK_LAYER_ID, + name: '', // will get replaced + config: defaultOptions, + location: { + mode: FrameGeometrySourceMode.Auto, + }, +}; + +/** + * Map layer configuration for network overlay + */ +export const networkLayer: MapLayerRegistryItem = { + 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, 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(1); + let legend: ReactNode = null; + if (config.showLegend) { + legend = ; + } + + 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; + + // 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(); +} diff --git a/public/app/plugins/panel/nodeGraph/types.ts b/public/app/plugins/panel/nodeGraph/types.ts index 8e2a1636c86..848437d26cf 100644 --- a/public/app/plugins/panel/nodeGraph/types.ts +++ b/public/app/plugins/panel/nodeGraph/types.ts @@ -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[]; +}; diff --git a/public/app/plugins/panel/nodeGraph/useCategorizeFrames.ts b/public/app/plugins/panel/nodeGraph/useCategorizeFrames.ts index a23162c313a..7b815a1d698 100644 --- a/public/app/plugins/panel/nodeGraph/useCategorizeFrames.ts +++ b/public/app/plugins/panel/nodeGraph/useCategorizeFrames.ts @@ -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]); } diff --git a/public/app/plugins/panel/nodeGraph/utils.ts b/public/app/plugins/panel/nodeGraph/utils.ts index 4fb8e5095a2..a0d533b72f7 100644 --- a/public/app/plugins/panel/nodeGraph/utils.ts +++ b/public/app/plugins/panel/nodeGraph/utils.ts @@ -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( + (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: [] } + ); +};