mirror of https://github.com/grafana/grafana
NodeGraph: Exploration mode (#33623)
* Add exploration option to node layout * Add hidden node count * Add grid layout option * Fix panning bounds calculation * Add legend with sorting * Allow sorting on any stats or arc value * Fix merge * Make sorting better * Reset focused node on layout change * Refactor limit hook a bit * Disable selected layout button * Don't show markers if only 1 node is hidden * Move legend to the bottom * Fix text backgrounds * Add show in graph layout action in grid layout * Center view on the focused node, fix perf issue when expanding big graph * Limit the node counting * Comment and linting fixes * Bit of code cleanup and comments * Add state for computing layout * Prevent computing map with partial data * Add rollup plugin for worker * Add rollup plugin for worker * Enhance data from worker * Fix perf issues with reduce and object creation * Improve comment * Fix tests * Css fixes * Remove worker plugin * Add comments * Fix test * Add test for exploration * Add test switching to grid layout * Apply suggestions from code review Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> * Remove unused plugin * Fix function name * Remove unused rollup plugin * Review fixes * Fix context menu shown on layout change * Make buttons bigger * Moved NodeGraph to core grafana Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>pull/34010/head
parent
290e00cb6f
commit
fdd6620d0a
@ -0,0 +1,12 @@ |
|||||||
|
export enum NodeGraphDataFrameFieldNames { |
||||||
|
id = 'id', |
||||||
|
title = 'title', |
||||||
|
subTitle = 'subTitle', |
||||||
|
mainStat = 'mainStat', |
||||||
|
secondaryStat = 'secondaryStat', |
||||||
|
source = 'source', |
||||||
|
target = 'target', |
||||||
|
detail = 'detail__', |
||||||
|
arc = 'arc__', |
||||||
|
color = 'color', |
||||||
|
} |
@ -1,2 +0,0 @@ |
|||||||
export { NodeGraph } from './NodeGraph'; |
|
||||||
export { DataFrameFieldNames as NodeGraphDataFrameFieldNames } from './utils'; |
|
@ -1,213 +0,0 @@ |
|||||||
import { useEffect, useState } from 'react'; |
|
||||||
import { forceSimulation, forceLink, forceCollide, forceX } from 'd3-force'; |
|
||||||
import { EdgeDatum, NodeDatum } from './types'; |
|
||||||
|
|
||||||
export interface Config { |
|
||||||
linkDistance: number; |
|
||||||
linkStrength: number; |
|
||||||
forceX: number; |
|
||||||
forceXStrength: number; |
|
||||||
forceCollide: number; |
|
||||||
tick: number; |
|
||||||
} |
|
||||||
|
|
||||||
export const defaultConfig: Config = { |
|
||||||
linkDistance: 150, |
|
||||||
linkStrength: 0.5, |
|
||||||
forceX: 2000, |
|
||||||
forceXStrength: 0.02, |
|
||||||
forceCollide: 100, |
|
||||||
tick: 300, |
|
||||||
}; |
|
||||||
|
|
||||||
/** |
|
||||||
* This will return copy of the nods and edges with x,y positions filled in. Also the layout changes source/target props |
|
||||||
* in edges from string ids to actual nodes. |
|
||||||
* TODO: the typing could probably be done better so it's clear that props are filled in after the layout |
|
||||||
*/ |
|
||||||
export function useLayout( |
|
||||||
rawNodes: NodeDatum[], |
|
||||||
rawEdges: EdgeDatum[], |
|
||||||
config: Config = defaultConfig |
|
||||||
): { bounds: Bounds; nodes: NodeDatum[]; edges: EdgeDatum[] } { |
|
||||||
const [nodes, setNodes] = useState<NodeDatum[]>([]); |
|
||||||
const [edges, setEdges] = useState<EdgeDatum[]>([]); |
|
||||||
|
|
||||||
// TODO the use effect is probably not needed here right now, but may make sense later if we decide to move the layout
|
|
||||||
// to webworker or just postpone until other things are rendered. Also right now it memoizes this for us.
|
|
||||||
useEffect(() => { |
|
||||||
if (rawNodes.length === 0) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// d3 just modifies the nodes directly, so lets make sure we don't leak that outside
|
|
||||||
const rawNodesCopy = rawNodes.map((n) => ({ ...n })); |
|
||||||
const rawEdgesCopy = rawEdges.map((e) => ({ ...e })); |
|
||||||
|
|
||||||
// Start withs some hardcoded positions so it starts laid out from left to right
|
|
||||||
let { roots, secondLevelRoots } = initializePositions(rawNodesCopy, rawEdgesCopy); |
|
||||||
|
|
||||||
// There always seems to be one or more root nodes each with single edge and we want to have them static on the
|
|
||||||
// left neatly in something like grid layout
|
|
||||||
[...roots, ...secondLevelRoots].forEach((n, index) => { |
|
||||||
n.fx = n.x; |
|
||||||
}); |
|
||||||
|
|
||||||
const simulation = forceSimulation(rawNodesCopy) |
|
||||||
.force( |
|
||||||
'link', |
|
||||||
forceLink(rawEdgesCopy) |
|
||||||
.id((d: any) => d.id) |
|
||||||
.distance(config.linkDistance) |
|
||||||
.strength(config.linkStrength) |
|
||||||
) |
|
||||||
// to keep the left to right layout we add force that pulls all nodes to right but because roots are fixed it will
|
|
||||||
// apply only to non root nodes
|
|
||||||
.force('x', forceX(config.forceX).strength(config.forceXStrength)) |
|
||||||
// Make sure nodes don't overlap
|
|
||||||
.force('collide', forceCollide(config.forceCollide)); |
|
||||||
|
|
||||||
// 300 ticks for the simulation are recommended but less would probably work too, most movement is done in first
|
|
||||||
// few iterations and then all the forces gets smaller https://github.com/d3/d3-force#simulation_alphaDecay
|
|
||||||
simulation.tick(config.tick); |
|
||||||
simulation.stop(); |
|
||||||
|
|
||||||
// We do centering here instead of using centering force to keep this more stable
|
|
||||||
centerNodes(rawNodesCopy); |
|
||||||
setNodes(rawNodesCopy); |
|
||||||
setEdges(rawEdgesCopy); |
|
||||||
}, [config, rawNodes, rawEdges]); |
|
||||||
|
|
||||||
return { |
|
||||||
nodes, |
|
||||||
edges, |
|
||||||
bounds: graphBounds(nodes) /* momeoize? loops over all nodes every time and we do it 2 times */, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* This initializes positions of the graph by going from the root to it's children and laying it out in a grid from left |
|
||||||
* to right. This works only so, so because service map graphs can have cycles and children levels are not ordered in a |
|
||||||
* way to minimize the edge lengths. Nevertheless this seems to make the graph easier to nudge with the forces later on |
|
||||||
* than with the d3 default initial positioning. Also we can fix the root positions later on for a bit more neat |
|
||||||
* organisation. |
|
||||||
* |
|
||||||
* This function directly modifies the nodes given and only returns references to root nodes so they do not have to be |
|
||||||
* found again later on. |
|
||||||
* |
|
||||||
* How the spacing could look like approximately: |
|
||||||
* 0 - 0 - 0 - 0 |
|
||||||
* \- 0 - 0 | |
|
||||||
* \- 0 -/ |
|
||||||
* 0 - 0 -/ |
|
||||||
*/ |
|
||||||
function initializePositions( |
|
||||||
nodes: NodeDatum[], |
|
||||||
edges: EdgeDatum[] |
|
||||||
): { roots: NodeDatum[]; secondLevelRoots: NodeDatum[] } { |
|
||||||
// To prevent going in cycles
|
|
||||||
const alreadyPositioned: { [id: string]: boolean } = {}; |
|
||||||
|
|
||||||
const nodesMap = nodes.reduce((acc, node) => ({ ...acc, [node.id]: node }), {} as Record<string, NodeDatum>); |
|
||||||
const edgesMap = edges.reduce((acc, edge) => { |
|
||||||
const sourceId = edge.source as number; |
|
||||||
return { |
|
||||||
...acc, |
|
||||||
[sourceId]: [...(acc[sourceId] || []), edge], |
|
||||||
}; |
|
||||||
}, {} as Record<string, EdgeDatum[]>); |
|
||||||
|
|
||||||
let roots = nodes.filter((n) => n.incoming === 0); |
|
||||||
|
|
||||||
let secondLevelRoots = roots.reduce<NodeDatum[]>( |
|
||||||
(acc, r) => [...acc, ...(edgesMap[r.id] ? edgesMap[r.id].map((e) => nodesMap[e.target as number]) : [])], |
|
||||||
[] |
|
||||||
); |
|
||||||
|
|
||||||
const rootYSpacing = 300; |
|
||||||
const nodeYSpacing = 200; |
|
||||||
const nodeXSpacing = 200; |
|
||||||
|
|
||||||
let rootY = 0; |
|
||||||
for (const root of roots) { |
|
||||||
let graphLevel = [root]; |
|
||||||
let x = 0; |
|
||||||
while (graphLevel.length > 0) { |
|
||||||
const nextGraphLevel: NodeDatum[] = []; |
|
||||||
let y = rootY; |
|
||||||
for (const node of graphLevel) { |
|
||||||
if (alreadyPositioned[node.id]) { |
|
||||||
continue; |
|
||||||
} |
|
||||||
// Initialize positions based on the spacing in the grid
|
|
||||||
node.x = x; |
|
||||||
node.y = y; |
|
||||||
alreadyPositioned[node.id] = true; |
|
||||||
|
|
||||||
// Move to next Y position for next node
|
|
||||||
y += nodeYSpacing; |
|
||||||
if (edgesMap[node.id]) { |
|
||||||
nextGraphLevel.push(...edgesMap[node.id].map((edge) => nodesMap[edge.target as number])); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
graphLevel = nextGraphLevel; |
|
||||||
// Move to next X position for next level
|
|
||||||
x += nodeXSpacing; |
|
||||||
// Reset Y back to baseline for this root
|
|
||||||
y = rootY; |
|
||||||
} |
|
||||||
rootY += rootYSpacing; |
|
||||||
} |
|
||||||
return { roots, secondLevelRoots }; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Makes sure that the center of the graph based on it's bound is in 0, 0 coordinates. |
|
||||||
* Modifies the nodes directly. |
|
||||||
*/ |
|
||||||
function centerNodes(nodes: NodeDatum[]) { |
|
||||||
const bounds = graphBounds(nodes); |
|
||||||
const middleY = bounds.top + (bounds.bottom - bounds.top) / 2; |
|
||||||
const middleX = bounds.left + (bounds.right - bounds.left) / 2; |
|
||||||
|
|
||||||
for (let node of nodes) { |
|
||||||
node.x = node.x! - middleX; |
|
||||||
node.y = node.y! - middleY; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export interface Bounds { |
|
||||||
top: number; |
|
||||||
right: number; |
|
||||||
bottom: number; |
|
||||||
left: number; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get bounds of the graph meaning the extent of the nodes in all directions. |
|
||||||
*/ |
|
||||||
function graphBounds(nodes: NodeDatum[]): Bounds { |
|
||||||
if (nodes.length === 0) { |
|
||||||
return { top: 0, right: 0, bottom: 0, left: 0 }; |
|
||||||
} |
|
||||||
|
|
||||||
return nodes.reduce( |
|
||||||
(acc, node) => { |
|
||||||
if (node.x! > acc.right) { |
|
||||||
acc.right = node.x!; |
|
||||||
} |
|
||||||
if (node.x! < acc.left) { |
|
||||||
acc.left = node.x!; |
|
||||||
} |
|
||||||
if (node.y! > acc.bottom) { |
|
||||||
acc.bottom = node.y!; |
|
||||||
} |
|
||||||
if (node.y! < acc.top) { |
|
||||||
acc.top = node.y!; |
|
||||||
} |
|
||||||
return acc; |
|
||||||
}, |
|
||||||
{ top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity } |
|
||||||
); |
|
||||||
} |
|
@ -1,22 +0,0 @@ |
|||||||
import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force'; |
|
||||||
|
|
||||||
export type NodeDatum = SimulationNodeDatum & { |
|
||||||
id: string; |
|
||||||
title: string; |
|
||||||
subTitle: string; |
|
||||||
dataFrameRowIndex: number; |
|
||||||
incoming: number; |
|
||||||
mainStat: string; |
|
||||||
secondaryStat: string; |
|
||||||
arcSections: Array<{ |
|
||||||
value: number; |
|
||||||
color: string; |
|
||||||
}>; |
|
||||||
color: string; |
|
||||||
}; |
|
||||||
export type EdgeDatum = SimulationLinkDatum<NodeDatum> & { |
|
||||||
id: string; |
|
||||||
mainStat: string; |
|
||||||
secondaryStat: string; |
|
||||||
dataFrameRowIndex: number; |
|
||||||
}; |
|
@ -1,50 +0,0 @@ |
|||||||
import { useMemo } from 'react'; |
|
||||||
import { EdgeDatum, NodeDatum } from './types'; |
|
||||||
|
|
||||||
/** |
|
||||||
* Limits the number of nodes by going from the roots breadth first until we have desired number of nodes. |
|
||||||
* TODO: there is some possible perf gains as some of the processing is the same as in layout and so we do double |
|
||||||
* the work. |
|
||||||
*/ |
|
||||||
export function useNodeLimit( |
|
||||||
nodes: NodeDatum[], |
|
||||||
edges: EdgeDatum[], |
|
||||||
limit: number |
|
||||||
): { nodes: NodeDatum[]; edges: EdgeDatum[] } { |
|
||||||
return useMemo(() => { |
|
||||||
if (nodes.length <= limit) { |
|
||||||
return { nodes, edges }; |
|
||||||
} |
|
||||||
|
|
||||||
const edgesMap = edges.reduce<{ [id: string]: EdgeDatum[] }>((acc, e) => { |
|
||||||
const sourceId = e.source as string; |
|
||||||
return { |
|
||||||
...acc, |
|
||||||
[sourceId]: [...(acc[sourceId] || []), e], |
|
||||||
}; |
|
||||||
}, {}); |
|
||||||
|
|
||||||
const nodesMap = nodes.reduce((acc, node) => ({ ...acc, [node.id]: node }), {} as Record<string, NodeDatum>); |
|
||||||
|
|
||||||
let roots = nodes.filter((n) => n.incoming === 0); |
|
||||||
const newNodes: Record<string, NodeDatum> = {}; |
|
||||||
const stack = [...roots]; |
|
||||||
|
|
||||||
while (Object.keys(newNodes).length < limit && stack.length > 0) { |
|
||||||
let current = stack.shift()!; |
|
||||||
if (newNodes[current!.id]) { |
|
||||||
continue; |
|
||||||
} |
|
||||||
|
|
||||||
newNodes[current.id] = current; |
|
||||||
const edges = edgesMap[current.id] || []; |
|
||||||
for (const edge of edges) { |
|
||||||
stack.push(nodesMap[edge.target as string]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const newEdges = edges.filter((e) => newNodes[e.source as string] && newNodes[e.target as string]); |
|
||||||
|
|
||||||
return { nodes: Object.values(newNodes), edges: newEdges }; |
|
||||||
}, [edges, limit, nodes]); |
|
||||||
} |
|
@ -1,113 +0,0 @@ |
|||||||
import { createTheme } from '@grafana/data'; |
|
||||||
import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils'; |
|
||||||
|
|
||||||
describe('processNodes', () => { |
|
||||||
const theme = createTheme(); |
|
||||||
|
|
||||||
it('handles empty args', async () => { |
|
||||||
expect(processNodes(undefined, undefined, theme)).toEqual({ nodes: [], edges: [] }); |
|
||||||
}); |
|
||||||
|
|
||||||
it('returns proper nodes and edges', async () => { |
|
||||||
expect( |
|
||||||
processNodes( |
|
||||||
makeNodesDataFrame(3), |
|
||||||
makeEdgesDataFrame([ |
|
||||||
[0, 1], |
|
||||||
[0, 2], |
|
||||||
[1, 2], |
|
||||||
]), |
|
||||||
theme |
|
||||||
) |
|
||||||
).toEqual({ |
|
||||||
nodes: [ |
|
||||||
{ |
|
||||||
arcSections: [ |
|
||||||
{ |
|
||||||
color: 'green', |
|
||||||
value: 0.5, |
|
||||||
}, |
|
||||||
{ |
|
||||||
color: 'red', |
|
||||||
value: 0.5, |
|
||||||
}, |
|
||||||
], |
|
||||||
color: 'rgb(226, 192, 61)', |
|
||||||
dataFrameRowIndex: 0, |
|
||||||
id: '0', |
|
||||||
incoming: 0, |
|
||||||
mainStat: '0.10', |
|
||||||
secondaryStat: '2.00', |
|
||||||
subTitle: 'service', |
|
||||||
title: 'service:0', |
|
||||||
}, |
|
||||||
{ |
|
||||||
arcSections: [ |
|
||||||
{ |
|
||||||
color: 'green', |
|
||||||
value: 0.5, |
|
||||||
}, |
|
||||||
{ |
|
||||||
color: 'red', |
|
||||||
value: 0.5, |
|
||||||
}, |
|
||||||
], |
|
||||||
color: 'rgb(226, 192, 61)', |
|
||||||
dataFrameRowIndex: 1, |
|
||||||
id: '1', |
|
||||||
incoming: 1, |
|
||||||
mainStat: '0.10', |
|
||||||
secondaryStat: '2.00', |
|
||||||
subTitle: 'service', |
|
||||||
title: 'service:1', |
|
||||||
}, |
|
||||||
{ |
|
||||||
arcSections: [ |
|
||||||
{ |
|
||||||
color: 'green', |
|
||||||
value: 0.5, |
|
||||||
}, |
|
||||||
{ |
|
||||||
color: 'red', |
|
||||||
value: 0.5, |
|
||||||
}, |
|
||||||
], |
|
||||||
color: 'rgb(226, 192, 61)', |
|
||||||
dataFrameRowIndex: 2, |
|
||||||
id: '2', |
|
||||||
incoming: 2, |
|
||||||
mainStat: '0.10', |
|
||||||
secondaryStat: '2.00', |
|
||||||
subTitle: 'service', |
|
||||||
title: 'service:2', |
|
||||||
}, |
|
||||||
], |
|
||||||
edges: [ |
|
||||||
{ |
|
||||||
dataFrameRowIndex: 0, |
|
||||||
id: '0--1', |
|
||||||
mainStat: '', |
|
||||||
secondaryStat: '', |
|
||||||
source: '0', |
|
||||||
target: '1', |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataFrameRowIndex: 1, |
|
||||||
id: '0--2', |
|
||||||
mainStat: '', |
|
||||||
secondaryStat: '', |
|
||||||
source: '0', |
|
||||||
target: '2', |
|
||||||
}, |
|
||||||
{ |
|
||||||
dataFrameRowIndex: 2, |
|
||||||
id: '1--2', |
|
||||||
mainStat: '', |
|
||||||
secondaryStat: '', |
|
||||||
source: '1', |
|
||||||
target: '2', |
|
||||||
}, |
|
||||||
], |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
@ -0,0 +1,15 @@ |
|||||||
|
/** |
||||||
|
* @deprecated use it from @grafana/data. Kept here for backward compatibility. |
||||||
|
*/ |
||||||
|
export enum NodeGraphDataFrameFieldNames { |
||||||
|
id = 'id', |
||||||
|
title = 'title', |
||||||
|
subTitle = 'subTitle', |
||||||
|
mainStat = 'mainStat', |
||||||
|
secondaryStat = 'secondaryStat', |
||||||
|
source = 'source', |
||||||
|
target = 'target', |
||||||
|
detail = 'detail__', |
||||||
|
arc = 'arc__', |
||||||
|
color = 'color', |
||||||
|
} |
@ -1,8 +1,8 @@ |
|||||||
import React, { memo } from 'react'; |
import React, { memo } from 'react'; |
||||||
import { EdgeDatum, NodeDatum } from './types'; |
import { EdgeDatum, NodeDatum } from './types'; |
||||||
import { css } from '@emotion/css'; |
import { css } from '@emotion/css'; |
||||||
import { useStyles2 } from '../../themes'; |
|
||||||
import { GrafanaTheme2 } from '@grafana/data'; |
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { useStyles2 } from '@grafana/ui'; |
||||||
import { shortenLine } from './utils'; |
import { shortenLine } from './utils'; |
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => { |
const getStyles = (theme: GrafanaTheme2) => { |
@ -0,0 +1,88 @@ |
|||||||
|
import React, { useCallback } from 'react'; |
||||||
|
import { NodeDatum } from './types'; |
||||||
|
import { Field, FieldColorModeId, getColorForTheme, GrafanaTheme } from '@grafana/data'; |
||||||
|
import { identity } from 'lodash'; |
||||||
|
import { Config } from './layout'; |
||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { Icon, LegendDisplayMode, useStyles, useTheme, VizLegend, VizLegendItem, VizLegendListItem } from '@grafana/ui'; |
||||||
|
|
||||||
|
function getStyles() { |
||||||
|
return { |
||||||
|
item: css` |
||||||
|
label: LegendItem; |
||||||
|
flex-grow: 0; |
||||||
|
`,
|
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
interface Props { |
||||||
|
nodes: NodeDatum[]; |
||||||
|
onSort: (sort: Config['sort']) => void; |
||||||
|
sort?: Config['sort']; |
||||||
|
sortable: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export const Legend = function Legend(props: Props) { |
||||||
|
const { nodes, onSort, sort, sortable } = props; |
||||||
|
|
||||||
|
const theme = useTheme(); |
||||||
|
const styles = useStyles(getStyles); |
||||||
|
const colorItems = getColorLegendItems(nodes, theme); |
||||||
|
|
||||||
|
const onClick = useCallback( |
||||||
|
(item) => { |
||||||
|
onSort({ |
||||||
|
field: item.data!.field, |
||||||
|
ascending: item.data!.field === sort?.field ? !sort?.ascending : true, |
||||||
|
}); |
||||||
|
}, |
||||||
|
[sort, onSort] |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<VizLegend<ItemData> |
||||||
|
displayMode={LegendDisplayMode.List} |
||||||
|
placement={'bottom'} |
||||||
|
items={colorItems} |
||||||
|
itemRenderer={(item) => { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<VizLegendListItem item={item} className={styles.item} onLabelClick={sortable ? onClick : undefined} /> |
||||||
|
{sortable && |
||||||
|
(sort?.field === item.data!.field ? <Icon name={sort!.ascending ? 'angle-up' : 'angle-down'} /> : '')} |
||||||
|
</> |
||||||
|
); |
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
interface ItemData { |
||||||
|
field: Field; |
||||||
|
} |
||||||
|
|
||||||
|
function getColorLegendItems(nodes: NodeDatum[], theme: GrafanaTheme): Array<VizLegendItem<ItemData>> { |
||||||
|
const fields = [nodes[0].mainStat, nodes[0].secondaryStat].filter(identity) as Field[]; |
||||||
|
|
||||||
|
const node = nodes.find((n) => n.arcSections.length > 0); |
||||||
|
if (node) { |
||||||
|
if (node.arcSections[0]!.config?.color?.mode === FieldColorModeId.Fixed) { |
||||||
|
// We assume in this case we have a set of fixed colors which map neatly into a basic legend.
|
||||||
|
|
||||||
|
// Lets collect and deduplicate as there isn't a requirement for 0 size arc section to be defined
|
||||||
|
fields.push(...new Set(nodes.map((n) => n.arcSections).flat())); |
||||||
|
} else { |
||||||
|
// TODO: probably some sort of gradient which we will have to deal with later
|
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return fields.map((f) => { |
||||||
|
return { |
||||||
|
label: f.config.displayName || f.name, |
||||||
|
color: getColorForTheme(f.config.color?.fixedColor || '', theme), |
||||||
|
yAxis: 0, |
||||||
|
data: { field: f }, |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,61 @@ |
|||||||
|
import React, { MouseEvent, memo } from 'react'; |
||||||
|
import { NodesMarker } from './types'; |
||||||
|
import { GrafanaTheme } from '@grafana/data'; |
||||||
|
import { css } from 'emotion'; |
||||||
|
import { stylesFactory, useTheme } from '@grafana/ui'; |
||||||
|
|
||||||
|
const nodeR = 40; |
||||||
|
|
||||||
|
const getStyles = stylesFactory((theme: GrafanaTheme) => ({ |
||||||
|
mainGroup: css` |
||||||
|
cursor: pointer; |
||||||
|
font-size: 10px; |
||||||
|
`,
|
||||||
|
|
||||||
|
mainCircle: css` |
||||||
|
fill: ${theme.colors.panelBg}; |
||||||
|
stroke: ${theme.colors.border3}; |
||||||
|
`,
|
||||||
|
text: css` |
||||||
|
width: 50px; |
||||||
|
height: 50px; |
||||||
|
text-align: center; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
`,
|
||||||
|
})); |
||||||
|
|
||||||
|
export const Marker = memo(function Marker(props: { |
||||||
|
marker: NodesMarker; |
||||||
|
onClick?: (event: MouseEvent<SVGElement>, marker: NodesMarker) => void; |
||||||
|
}) { |
||||||
|
const { marker, onClick } = props; |
||||||
|
const { node } = marker; |
||||||
|
const styles = getStyles(useTheme()); |
||||||
|
|
||||||
|
if (!(node.x !== undefined && node.y !== undefined)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<g |
||||||
|
data-node-id={node.id} |
||||||
|
className={styles.mainGroup} |
||||||
|
onClick={(event) => { |
||||||
|
onClick?.(event, marker); |
||||||
|
}} |
||||||
|
aria-label={`Hidden nodes marker: ${node.id}`} |
||||||
|
> |
||||||
|
<circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} /> |
||||||
|
<g> |
||||||
|
<foreignObject x={node.x - 25} y={node.y - 25} width="50" height="50"> |
||||||
|
<div className={styles.text}> |
||||||
|
{/* we limit the count to 101 so if we have more than 100 nodes we don't have exact count */} |
||||||
|
<span>{marker.count > 100 ? '>100' : marker.count} nodes</span> |
||||||
|
</div> |
||||||
|
</foreignObject> |
||||||
|
</g> |
||||||
|
</g> |
||||||
|
); |
||||||
|
}); |
@ -0,0 +1 @@ |
|||||||
|
export { NodeGraph } from './NodeGraph'; |
@ -0,0 +1,183 @@ |
|||||||
|
import { useEffect, useMemo, useState } from 'react'; |
||||||
|
import { EdgeDatum, EdgeDatumLayout, NodeDatum } from './types'; |
||||||
|
import { Field } from '@grafana/data'; |
||||||
|
import { useNodeLimit } from './useNodeLimit'; |
||||||
|
import useMountedState from 'react-use/lib/useMountedState'; |
||||||
|
import { graphBounds } from './utils'; |
||||||
|
// @ts-ignore
|
||||||
|
import LayoutWorker from './layout.worker.js'; |
||||||
|
|
||||||
|
export interface Config { |
||||||
|
linkDistance: number; |
||||||
|
linkStrength: number; |
||||||
|
forceX: number; |
||||||
|
forceXStrength: number; |
||||||
|
forceCollide: number; |
||||||
|
tick: number; |
||||||
|
gridLayout: boolean; |
||||||
|
sort?: { |
||||||
|
// Either a arc field or stats field
|
||||||
|
field: Field; |
||||||
|
ascending: boolean; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Config mainly for the layout but also some other parts like current layout. The layout variables can be changed only
|
||||||
|
// if you programmatically enable the config editor (for development only) see ViewControls. These could be moved to
|
||||||
|
// panel configuration at some point (apart from gridLayout as that can be switched be user right now.).
|
||||||
|
export const defaultConfig: Config = { |
||||||
|
linkDistance: 150, |
||||||
|
linkStrength: 0.5, |
||||||
|
forceX: 2000, |
||||||
|
forceXStrength: 0.02, |
||||||
|
forceCollide: 100, |
||||||
|
tick: 300, |
||||||
|
gridLayout: false, |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* This will return copy of the nods and edges with x,y positions filled in. Also the layout changes source/target props |
||||||
|
* in edges from string ids to actual nodes. |
||||||
|
*/ |
||||||
|
export function useLayout( |
||||||
|
rawNodes: NodeDatum[], |
||||||
|
rawEdges: EdgeDatum[], |
||||||
|
config: Config = defaultConfig, |
||||||
|
nodeCountLimit: number, |
||||||
|
rootNodeId?: string |
||||||
|
) { |
||||||
|
const [nodesGrid, setNodesGrid] = useState<NodeDatum[]>([]); |
||||||
|
const [edgesGrid, setEdgesGrid] = useState<EdgeDatumLayout[]>([]); |
||||||
|
|
||||||
|
const [nodesGraph, setNodesGraph] = useState<NodeDatum[]>([]); |
||||||
|
const [edgesGraph, setEdgesGraph] = useState<EdgeDatumLayout[]>([]); |
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false); |
||||||
|
|
||||||
|
const isMounted = useMountedState(); |
||||||
|
|
||||||
|
// Also we compute both layouts here. Grid layout should not add much time and we can more easily just cache both
|
||||||
|
// so this should happen only once for a given response data.
|
||||||
|
//
|
||||||
|
// Also important note is that right now this works on all the nodes even if they are not visible. This means that
|
||||||
|
// the node position is stable even when expanding different parts of graph. It seems like a reasonable thing but
|
||||||
|
// implications are that:
|
||||||
|
// - limiting visible nodes count does not have a positive perf effect
|
||||||
|
// - graphs with high node count can seem weird (very sparse or spread out) when we show only some nodes but layout
|
||||||
|
// is done for thousands of nodes but we also do this only once in the graph lifecycle.
|
||||||
|
// We could re-layout this on visible nodes change but this may need smaller visible node limit to keep the perf
|
||||||
|
// (as we would run layout on every click) and also would be very weird without any animation to understand what is
|
||||||
|
// happening as already visible nodes would change positions.
|
||||||
|
useEffect(() => { |
||||||
|
if (rawNodes.length === 0) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setLoading(true); |
||||||
|
|
||||||
|
// d3 just modifies the nodes directly, so lets make sure we don't leak that outside
|
||||||
|
let rawNodesCopy = rawNodes.map((n) => ({ ...n })); |
||||||
|
let rawEdgesCopy = rawEdges.map((e) => ({ ...e })); |
||||||
|
|
||||||
|
// This is async but as I wanted to still run the sync grid layout and you cannot return promise from effect having
|
||||||
|
// callback seem ok here.
|
||||||
|
defaultLayout(rawNodesCopy, rawEdgesCopy, ({ nodes, edges }) => { |
||||||
|
// TODO: it would be better to cancel the worker somehow but probably not super important right now.
|
||||||
|
if (isMounted()) { |
||||||
|
setNodesGraph(nodes); |
||||||
|
setEdgesGraph(edges as EdgeDatumLayout[]); |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
rawNodesCopy = rawNodes.map((n) => ({ ...n })); |
||||||
|
rawEdgesCopy = rawEdges.map((e) => ({ ...e })); |
||||||
|
gridLayout(rawNodesCopy, config.sort); |
||||||
|
|
||||||
|
setNodesGrid(rawNodesCopy); |
||||||
|
setEdgesGrid(rawEdgesCopy as EdgeDatumLayout[]); |
||||||
|
}, [config.sort, rawNodes, rawEdges, isMounted]); |
||||||
|
|
||||||
|
// Limit the nodes so we don't show all for performance reasons. Here we don't compute both at the same time so
|
||||||
|
// changing the layout can trash internal memoization at the moment.
|
||||||
|
const { nodes: nodesWithLimit, edges: edgesWithLimit, markers } = useNodeLimit( |
||||||
|
config.gridLayout ? nodesGrid : nodesGraph, |
||||||
|
config.gridLayout ? edgesGrid : edgesGraph, |
||||||
|
nodeCountLimit, |
||||||
|
config, |
||||||
|
rootNodeId |
||||||
|
); |
||||||
|
|
||||||
|
// Get bounds based on current limited number of nodes.
|
||||||
|
const bounds = useMemo(() => graphBounds([...nodesWithLimit, ...(markers || []).map((m) => m.node)]), [ |
||||||
|
nodesWithLimit, |
||||||
|
markers, |
||||||
|
]); |
||||||
|
|
||||||
|
return { |
||||||
|
nodes: nodesWithLimit, |
||||||
|
edges: edgesWithLimit, |
||||||
|
markers, |
||||||
|
bounds, |
||||||
|
hiddenNodesCount: rawNodes.length - nodesWithLimit.length, |
||||||
|
loading, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Wraps the layout code in a worker as it can take long and we don't want to block the main thread. |
||||||
|
*/ |
||||||
|
function defaultLayout( |
||||||
|
nodes: NodeDatum[], |
||||||
|
edges: EdgeDatum[], |
||||||
|
done: (data: { nodes: NodeDatum[]; edges: EdgeDatum[] }) => void |
||||||
|
) { |
||||||
|
const worker = new LayoutWorker(); |
||||||
|
worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => { |
||||||
|
for (let i = 0; i < nodes.length; i++) { |
||||||
|
// These stats needs to be Field class but the data is stringified over the worker boundary
|
||||||
|
event.data.nodes[i] = { |
||||||
|
...event.data.nodes[i], |
||||||
|
mainStat: nodes[i].mainStat, |
||||||
|
secondaryStat: nodes[i].secondaryStat, |
||||||
|
arcSections: nodes[i].arcSections, |
||||||
|
}; |
||||||
|
} |
||||||
|
done(event.data); |
||||||
|
}; |
||||||
|
|
||||||
|
worker.postMessage({ nodes, edges, config: defaultConfig }); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set the nodes in simple grid layout sorted by some stat. |
||||||
|
*/ |
||||||
|
function gridLayout( |
||||||
|
nodes: NodeDatum[], |
||||||
|
sort?: { |
||||||
|
field: Field; |
||||||
|
ascending: boolean; |
||||||
|
} |
||||||
|
) { |
||||||
|
const spacingVertical = 140; |
||||||
|
const spacingHorizontal = 120; |
||||||
|
// TODO probably make this based on the width of the screen
|
||||||
|
const perRow = 4; |
||||||
|
|
||||||
|
if (sort) { |
||||||
|
nodes.sort((node1, node2) => { |
||||||
|
const val1 = sort!.field.values.get(node1.dataFrameRowIndex); |
||||||
|
const val2 = sort!.field.values.get(node2.dataFrameRowIndex); |
||||||
|
|
||||||
|
// Lets pretend we don't care about type for a while
|
||||||
|
return sort!.ascending ? val2 - val1 : val1 - val2; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
for (const [index, node] of nodes.entries()) { |
||||||
|
const row = Math.floor(index / perRow); |
||||||
|
const column = index % perRow; |
||||||
|
node.x = -180 + column * spacingHorizontal; |
||||||
|
node.y = -60 + row * spacingVertical; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,176 @@ |
|||||||
|
import { forceSimulation, forceLink, forceCollide, forceX } from 'd3-force'; |
||||||
|
|
||||||
|
addEventListener('message', (event) => { |
||||||
|
const { nodes, edges, config } = event.data; |
||||||
|
layout(nodes, edges, config); |
||||||
|
postMessage({ nodes, edges }); |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* Use d3 force layout to lay the nodes in a sensible way. This function modifies the nodes adding the x,y positions |
||||||
|
* and also fills in node references in edges instead of node ids. |
||||||
|
*/ |
||||||
|
export function layout(nodes, edges, config) { |
||||||
|
// Start with some hardcoded positions so it starts laid out from left to right
|
||||||
|
let { roots, secondLevelRoots } = initializePositions(nodes, edges); |
||||||
|
|
||||||
|
// There always seems to be one or more root nodes each with single edge and we want to have them static on the
|
||||||
|
// left neatly in something like grid layout
|
||||||
|
[...roots, ...secondLevelRoots].forEach((n, index) => { |
||||||
|
n.fx = n.x; |
||||||
|
}); |
||||||
|
|
||||||
|
const simulation = forceSimulation(nodes) |
||||||
|
.force( |
||||||
|
'link', |
||||||
|
forceLink(edges) |
||||||
|
.id((d) => d.id) |
||||||
|
.distance(config.linkDistance) |
||||||
|
.strength(config.linkStrength) |
||||||
|
) |
||||||
|
// to keep the left to right layout we add force that pulls all nodes to right but because roots are fixed it will
|
||||||
|
// apply only to non root nodes
|
||||||
|
.force('x', forceX(config.forceX).strength(config.forceXStrength)) |
||||||
|
// Make sure nodes don't overlap
|
||||||
|
.force('collide', forceCollide(config.forceCollide)); |
||||||
|
|
||||||
|
// 300 ticks for the simulation are recommended but less would probably work too, most movement is done in first
|
||||||
|
// few iterations and then all the forces gets smaller https://github.com/d3/d3-force#simulation_alphaDecay
|
||||||
|
simulation.tick(config.tick); |
||||||
|
simulation.stop(); |
||||||
|
|
||||||
|
// We do centering here instead of using centering force to keep this more stable
|
||||||
|
centerNodes(nodes); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* This initializes positions of the graph by going from the root to it's children and laying it out in a grid from left |
||||||
|
* to right. This works only so, so because service map graphs can have cycles and children levels are not ordered in a |
||||||
|
* way to minimize the edge lengths. Nevertheless this seems to make the graph easier to nudge with the forces later on |
||||||
|
* than with the d3 default initial positioning. Also we can fix the root positions later on for a bit more neat |
||||||
|
* organisation. |
||||||
|
* |
||||||
|
* This function directly modifies the nodes given and only returns references to root nodes so they do not have to be |
||||||
|
* found again later on. |
||||||
|
* |
||||||
|
* How the spacing could look like approximately: |
||||||
|
* 0 - 0 - 0 - 0 |
||||||
|
* \- 0 - 0 | |
||||||
|
* \- 0 -/ |
||||||
|
* 0 - 0 -/ |
||||||
|
*/ |
||||||
|
function initializePositions(nodes, edges) { |
||||||
|
// To prevent going in cycles
|
||||||
|
const alreadyPositioned = {}; |
||||||
|
|
||||||
|
const nodesMap = nodes.reduce((acc, node) => { |
||||||
|
acc[node.id] = node; |
||||||
|
return acc; |
||||||
|
}, {}); |
||||||
|
const edgesMap = edges.reduce((acc, edge) => { |
||||||
|
const sourceId = edge.source; |
||||||
|
acc[sourceId] = [...(acc[sourceId] || []), edge]; |
||||||
|
return acc; |
||||||
|
}, {}); |
||||||
|
|
||||||
|
let roots = nodes.filter((n) => n.incoming === 0); |
||||||
|
|
||||||
|
// For things like service maps we assume there is some root (client) node but if there is none then selecting
|
||||||
|
// any node as a starting point should work the same.
|
||||||
|
if (!roots.length) { |
||||||
|
roots = [nodes[0]]; |
||||||
|
} |
||||||
|
|
||||||
|
let secondLevelRoots = roots.reduce((acc, r) => { |
||||||
|
acc.push(...(edgesMap[r.id] ? edgesMap[r.id].map((e) => nodesMap[e.target]) : [])); |
||||||
|
return acc; |
||||||
|
}, []); |
||||||
|
|
||||||
|
const rootYSpacing = 300; |
||||||
|
const nodeYSpacing = 200; |
||||||
|
const nodeXSpacing = 200; |
||||||
|
|
||||||
|
let rootY = 0; |
||||||
|
for (const root of roots) { |
||||||
|
let graphLevel = [root]; |
||||||
|
let x = 0; |
||||||
|
while (graphLevel.length > 0) { |
||||||
|
const nextGraphLevel = []; |
||||||
|
let y = rootY; |
||||||
|
for (const node of graphLevel) { |
||||||
|
if (alreadyPositioned[node.id]) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
// Initialize positions based on the spacing in the grid
|
||||||
|
node.x = x; |
||||||
|
node.y = y; |
||||||
|
alreadyPositioned[node.id] = true; |
||||||
|
|
||||||
|
// Move to next Y position for next node
|
||||||
|
y += nodeYSpacing; |
||||||
|
if (edgesMap[node.id]) { |
||||||
|
nextGraphLevel.push(...edgesMap[node.id].map((edge) => nodesMap[edge.target])); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
graphLevel = nextGraphLevel; |
||||||
|
// Move to next X position for next level
|
||||||
|
x += nodeXSpacing; |
||||||
|
// Reset Y back to baseline for this root
|
||||||
|
y = rootY; |
||||||
|
} |
||||||
|
rootY += rootYSpacing; |
||||||
|
} |
||||||
|
return { roots, secondLevelRoots }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Makes sure that the center of the graph based on it's bound is in 0, 0 coordinates. |
||||||
|
* Modifies the nodes directly. |
||||||
|
*/ |
||||||
|
function centerNodes(nodes) { |
||||||
|
const bounds = graphBounds(nodes); |
||||||
|
for (let node of nodes) { |
||||||
|
node.x = node.x - bounds.center.x; |
||||||
|
node.y = node.y - bounds.center.y; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get bounds of the graph meaning the extent of the nodes in all directions. |
||||||
|
*/ |
||||||
|
function graphBounds(nodes) { |
||||||
|
if (nodes.length === 0) { |
||||||
|
return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } }; |
||||||
|
} |
||||||
|
|
||||||
|
const bounds = nodes.reduce( |
||||||
|
(acc, node) => { |
||||||
|
if (node.x > acc.right) { |
||||||
|
acc.right = node.x; |
||||||
|
} |
||||||
|
if (node.x < acc.left) { |
||||||
|
acc.left = node.x; |
||||||
|
} |
||||||
|
if (node.y > acc.bottom) { |
||||||
|
acc.bottom = node.y; |
||||||
|
} |
||||||
|
if (node.y < acc.top) { |
||||||
|
acc.top = node.y; |
||||||
|
} |
||||||
|
return acc; |
||||||
|
}, |
||||||
|
{ top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity } |
||||||
|
); |
||||||
|
|
||||||
|
const y = bounds.top + (bounds.bottom - bounds.top) / 2; |
||||||
|
const x = bounds.left + (bounds.right - bounds.left) / 2; |
||||||
|
|
||||||
|
return { |
||||||
|
...bounds, |
||||||
|
center: { |
||||||
|
x, |
||||||
|
y, |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
@ -1 +1,41 @@ |
|||||||
|
import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force'; |
||||||
|
import { Field } from '@grafana/data'; |
||||||
|
|
||||||
export interface Options {} |
export interface Options {} |
||||||
|
|
||||||
|
export type NodeDatum = SimulationNodeDatum & { |
||||||
|
id: string; |
||||||
|
title: string; |
||||||
|
subTitle: string; |
||||||
|
dataFrameRowIndex: number; |
||||||
|
incoming: number; |
||||||
|
mainStat?: Field; |
||||||
|
secondaryStat?: Field; |
||||||
|
arcSections: Field[]; |
||||||
|
color: string; |
||||||
|
}; |
||||||
|
|
||||||
|
// This is the data we have before the graph is laid out with source and target being string IDs.
|
||||||
|
type LinkDatum = SimulationLinkDatum<NodeDatum> & { |
||||||
|
source: string; |
||||||
|
target: string; |
||||||
|
}; |
||||||
|
|
||||||
|
// This is some additional data we expect with the edges.
|
||||||
|
export type EdgeDatum = LinkDatum & { |
||||||
|
id: string; |
||||||
|
mainStat: string; |
||||||
|
secondaryStat: string; |
||||||
|
dataFrameRowIndex: number; |
||||||
|
}; |
||||||
|
|
||||||
|
// After layout is run D3 will change the string IDs for actual references to the nodes.
|
||||||
|
export type EdgeDatumLayout = EdgeDatum & { |
||||||
|
source: NodeDatum; |
||||||
|
target: NodeDatum; |
||||||
|
}; |
||||||
|
|
||||||
|
export type NodesMarker = { |
||||||
|
node: NodeDatum; |
||||||
|
count: number; |
||||||
|
}; |
||||||
|
@ -0,0 +1,19 @@ |
|||||||
|
import usePrevious from 'react-use/lib/usePrevious'; |
||||||
|
import { Config } from './layout'; |
||||||
|
import { NodeDatum } from './types'; |
||||||
|
|
||||||
|
export function useFocusPositionOnLayout(config: Config, nodes: NodeDatum[], focusedNodeId: string | undefined) { |
||||||
|
const prevLayoutGrid = usePrevious(config.gridLayout); |
||||||
|
let focusPosition; |
||||||
|
if (prevLayoutGrid === true && !config.gridLayout && focusedNodeId) { |
||||||
|
const node = nodes.find((n) => n.id === focusedNodeId); |
||||||
|
if (node) { |
||||||
|
focusPosition = { |
||||||
|
x: -node.x!, |
||||||
|
y: -node.y!, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return focusPosition; |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import useMountedState from 'react-use/lib/useMountedState'; |
||||||
|
|
||||||
|
export function useHighlight(focusedNodeId?: string) { |
||||||
|
const [highlightId, setHighlightId] = useState<string>(); |
||||||
|
const mounted = useMountedState(); |
||||||
|
useEffect(() => { |
||||||
|
if (focusedNodeId) { |
||||||
|
setHighlightId(focusedNodeId); |
||||||
|
setTimeout(() => { |
||||||
|
if (mounted()) { |
||||||
|
setHighlightId(undefined); |
||||||
|
} |
||||||
|
}, 500); |
||||||
|
} |
||||||
|
}, [focusedNodeId, mounted]); |
||||||
|
|
||||||
|
return highlightId; |
||||||
|
} |
@ -0,0 +1,225 @@ |
|||||||
|
import { fromPairs, uniq } from 'lodash'; |
||||||
|
import { useMemo } from 'react'; |
||||||
|
import { EdgeDatumLayout, NodeDatum, NodesMarker } from './types'; |
||||||
|
import { Config } from './layout'; |
||||||
|
|
||||||
|
type NodesMap = Record<string, NodeDatum>; |
||||||
|
type EdgesMap = Record<string, EdgeDatumLayout[]>; |
||||||
|
|
||||||
|
/** |
||||||
|
* Limits the number of nodes by going from the roots breadth first until we have desired number of nodes. |
||||||
|
*/ |
||||||
|
export function useNodeLimit( |
||||||
|
nodes: NodeDatum[], |
||||||
|
edges: EdgeDatumLayout[], |
||||||
|
limit: number, |
||||||
|
config: Config, |
||||||
|
rootId?: string |
||||||
|
): { nodes: NodeDatum[]; edges: EdgeDatumLayout[]; markers?: NodesMarker[] } { |
||||||
|
// This is pretty expensive also this happens once in the layout code when initializing position but it's a bit
|
||||||
|
// tricky to do it only once and reuse the results because layout directly modifies the nodes.
|
||||||
|
const [edgesMap, nodesMap] = useMemo(() => { |
||||||
|
// Make sure we don't compute this until we have all the data.
|
||||||
|
if (!(nodes.length && edges.length)) { |
||||||
|
return [{}, {}]; |
||||||
|
} |
||||||
|
|
||||||
|
const edgesMap = edges.reduce<EdgesMap>((acc, e) => { |
||||||
|
acc[e.source.id] = [...(acc[e.source.id] ?? []), e]; |
||||||
|
acc[e.target.id] = [...(acc[e.target.id] ?? []), e]; |
||||||
|
return acc; |
||||||
|
}, {}); |
||||||
|
|
||||||
|
const nodesMap = nodes.reduce<NodesMap>((acc, node) => { |
||||||
|
acc[node.id] = node; |
||||||
|
return acc; |
||||||
|
}, {}); |
||||||
|
return [edgesMap, nodesMap]; |
||||||
|
}, [edges, nodes]); |
||||||
|
|
||||||
|
return useMemo(() => { |
||||||
|
if (nodes.length <= limit) { |
||||||
|
return { nodes, edges }; |
||||||
|
} |
||||||
|
|
||||||
|
if (config.gridLayout) { |
||||||
|
return limitGridLayout(nodes, limit, rootId); |
||||||
|
} |
||||||
|
|
||||||
|
return limitGraphLayout(nodes, edges, nodesMap, edgesMap, limit, rootId); |
||||||
|
}, [edges, edgesMap, limit, nodes, nodesMap, rootId, config.gridLayout]); |
||||||
|
} |
||||||
|
|
||||||
|
export function limitGraphLayout( |
||||||
|
nodes: NodeDatum[], |
||||||
|
edges: EdgeDatumLayout[], |
||||||
|
nodesMap: NodesMap, |
||||||
|
edgesMap: EdgesMap, |
||||||
|
limit: number, |
||||||
|
rootId?: string |
||||||
|
) { |
||||||
|
let roots; |
||||||
|
if (rootId) { |
||||||
|
roots = [nodesMap[rootId]]; |
||||||
|
} else { |
||||||
|
roots = nodes.filter((n) => n.incoming === 0); |
||||||
|
// TODO: same code as layout
|
||||||
|
if (!roots.length) { |
||||||
|
roots = [nodes[0]]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const { visibleNodes, markers } = collectVisibleNodes(limit, roots, nodesMap, edgesMap); |
||||||
|
|
||||||
|
const markersWithStats = collectMarkerStats(markers, visibleNodes, nodesMap, edgesMap); |
||||||
|
const markersMap = fromPairs(markersWithStats.map((m) => [m.node.id, m])); |
||||||
|
|
||||||
|
for (const marker of markersWithStats) { |
||||||
|
if (marker.count === 1) { |
||||||
|
delete markersMap[marker.node.id]; |
||||||
|
visibleNodes[marker.node.id] = marker.node; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Show all edges between visible nodes or placeholder markers
|
||||||
|
const visibleEdges = edges.filter( |
||||||
|
(e) => |
||||||
|
(visibleNodes[e.source.id] || markersMap[e.source.id]) && (visibleNodes[e.target.id] || markersMap[e.target.id]) |
||||||
|
); |
||||||
|
|
||||||
|
return { |
||||||
|
nodes: Object.values(visibleNodes), |
||||||
|
edges: visibleEdges, |
||||||
|
markers: Object.values(markersMap), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export function limitGridLayout(nodes: NodeDatum[], limit: number, rootId?: string) { |
||||||
|
let start = 0; |
||||||
|
let stop = limit; |
||||||
|
let markers: NodesMarker[] = []; |
||||||
|
|
||||||
|
if (rootId) { |
||||||
|
const index = nodes.findIndex((node) => node.id === rootId); |
||||||
|
const prevLimit = Math.floor(limit / 2); |
||||||
|
let afterLimit = prevLimit; |
||||||
|
start = index - prevLimit; |
||||||
|
if (start < 0) { |
||||||
|
afterLimit += Math.abs(start); |
||||||
|
start = 0; |
||||||
|
} |
||||||
|
stop = index + afterLimit + 1; |
||||||
|
|
||||||
|
if (stop > nodes.length) { |
||||||
|
if (start > 0) { |
||||||
|
start = Math.max(0, start - (stop - nodes.length)); |
||||||
|
} |
||||||
|
stop = nodes.length; |
||||||
|
} |
||||||
|
|
||||||
|
if (start > 1) { |
||||||
|
markers.push({ node: nodes[start - 1], count: start }); |
||||||
|
} |
||||||
|
|
||||||
|
if (nodes.length - stop > 1) { |
||||||
|
markers.push({ node: nodes[stop], count: nodes.length - stop }); |
||||||
|
} |
||||||
|
} else { |
||||||
|
if (nodes.length - limit > 1) { |
||||||
|
markers = [{ node: nodes[limit], count: nodes.length - limit }]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
nodes: nodes.slice(start, stop), |
||||||
|
edges: [], |
||||||
|
markers, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Breath first traverse of the graph collecting all the nodes until we reach the limit. It also returns markers which |
||||||
|
* are nodes on the edges which did not make it into the limit but can be used as clickable markers for manually |
||||||
|
* expanding the graph. |
||||||
|
* @param limit |
||||||
|
* @param roots - Nodes where to start the traversal. In case of exploration this can be any node that user clicked on. |
||||||
|
* @param nodesMap - Node id to node |
||||||
|
* @param edgesMap - This is a map of node id to a list of edges (both ingoing and outgoing) |
||||||
|
*/ |
||||||
|
function collectVisibleNodes( |
||||||
|
limit: number, |
||||||
|
roots: NodeDatum[], |
||||||
|
nodesMap: Record<string, NodeDatum>, |
||||||
|
edgesMap: Record<string, EdgeDatumLayout[]> |
||||||
|
): { visibleNodes: Record<string, NodeDatum>; markers: NodeDatum[] } { |
||||||
|
const visibleNodes: Record<string, NodeDatum> = {}; |
||||||
|
let stack = [...roots]; |
||||||
|
|
||||||
|
while (Object.keys(visibleNodes).length < limit && stack.length > 0) { |
||||||
|
let current = stack.shift()!; |
||||||
|
|
||||||
|
// We are already showing this node. This can happen because graphs can be cyclic
|
||||||
|
if (visibleNodes[current!.id]) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Show this node
|
||||||
|
visibleNodes[current.id] = current; |
||||||
|
const edges = edgesMap[current.id] || []; |
||||||
|
|
||||||
|
// Add any nodes that are connected to it on top of the stack to be considered in the next pass
|
||||||
|
const connectedNodes = edges.map((e) => { |
||||||
|
// We don't care about direction here. Should not make much difference but argument could be made that with
|
||||||
|
// directed graphs it should walk the graph directionally. Problem is when we focus on a node in the middle of
|
||||||
|
// graph (not going from the "natural" root) we also want to show what was "before".
|
||||||
|
const id = e.source.id === current.id ? e.target.id : e.source.id; |
||||||
|
return nodesMap[id]; |
||||||
|
}); |
||||||
|
stack = stack.concat(connectedNodes); |
||||||
|
} |
||||||
|
|
||||||
|
// Right now our stack contains all the nodes which are directly connected to the graph but did not make the cut.
|
||||||
|
// Some of them though can be nodes we already are showing so we have to filter them and then use them as markers.
|
||||||
|
const markers = uniq(stack.filter((n) => !visibleNodes[n.id])); |
||||||
|
|
||||||
|
return { visibleNodes, markers }; |
||||||
|
} |
||||||
|
|
||||||
|
function collectMarkerStats( |
||||||
|
markers: NodeDatum[], |
||||||
|
visibleNodes: Record<string, NodeDatum>, |
||||||
|
nodesMap: Record<string, NodeDatum>, |
||||||
|
edgesMap: Record<string, EdgeDatumLayout[]> |
||||||
|
): NodesMarker[] { |
||||||
|
return markers.map((marker) => { |
||||||
|
const nodesToCount: Record<string, NodeDatum> = {}; |
||||||
|
let count = 0; |
||||||
|
let stack = [marker]; |
||||||
|
while (stack.length > 0 && count <= 101) { |
||||||
|
let current = stack.shift()!; |
||||||
|
|
||||||
|
// We are showing this node so not going to count it as hidden.
|
||||||
|
if (visibleNodes[current.id] || nodesToCount[current.id]) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (!nodesToCount[current.id]) { |
||||||
|
count++; |
||||||
|
} |
||||||
|
nodesToCount[current.id] = current; |
||||||
|
|
||||||
|
const edges = edgesMap[current.id] || []; |
||||||
|
|
||||||
|
const connectedNodes = edges.map((e) => { |
||||||
|
const id = e.source.id === current.id ? e.target.id : e.source.id; |
||||||
|
return nodesMap[id]; |
||||||
|
}); |
||||||
|
stack = stack.concat(connectedNodes); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
node: marker, |
||||||
|
count: count, |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,195 @@ |
|||||||
|
import { ArrayVector, createTheme } from '@grafana/data'; |
||||||
|
import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils'; |
||||||
|
|
||||||
|
describe('processNodes', () => { |
||||||
|
const theme = createTheme(); |
||||||
|
|
||||||
|
it('handles empty args', async () => { |
||||||
|
expect(processNodes(undefined, undefined, theme)).toEqual({ nodes: [], edges: [] }); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns proper nodes and edges', async () => { |
||||||
|
const { nodes, edges, legend } = processNodes( |
||||||
|
makeNodesDataFrame(3), |
||||||
|
makeEdgesDataFrame([ |
||||||
|
[0, 1], |
||||||
|
[0, 2], |
||||||
|
[1, 2], |
||||||
|
]), |
||||||
|
theme |
||||||
|
); |
||||||
|
|
||||||
|
expect(nodes).toEqual([ |
||||||
|
{ |
||||||
|
arcSections: [ |
||||||
|
{ |
||||||
|
config: { |
||||||
|
color: { |
||||||
|
fixedColor: 'green', |
||||||
|
}, |
||||||
|
}, |
||||||
|
name: 'arc__success', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]), |
||||||
|
}, |
||||||
|
{ |
||||||
|
config: { |
||||||
|
color: { |
||||||
|
fixedColor: 'red', |
||||||
|
}, |
||||||
|
}, |
||||||
|
name: 'arc__errors', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]), |
||||||
|
}, |
||||||
|
], |
||||||
|
color: 'rgb(226, 192, 61)', |
||||||
|
dataFrameRowIndex: 0, |
||||||
|
id: '0', |
||||||
|
incoming: 0, |
||||||
|
mainStat: { |
||||||
|
config: {}, |
||||||
|
index: 3, |
||||||
|
name: 'mainStat', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([0.1, 0.1, 0.1]), |
||||||
|
}, |
||||||
|
secondaryStat: { |
||||||
|
config: {}, |
||||||
|
index: 4, |
||||||
|
name: 'secondaryStat', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([2, 2, 2]), |
||||||
|
}, |
||||||
|
subTitle: 'service', |
||||||
|
title: 'service:0', |
||||||
|
}, |
||||||
|
{ |
||||||
|
arcSections: [ |
||||||
|
{ |
||||||
|
config: { |
||||||
|
color: { |
||||||
|
fixedColor: 'green', |
||||||
|
}, |
||||||
|
}, |
||||||
|
name: 'arc__success', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]), |
||||||
|
}, |
||||||
|
{ |
||||||
|
config: { |
||||||
|
color: { |
||||||
|
fixedColor: 'red', |
||||||
|
}, |
||||||
|
}, |
||||||
|
name: 'arc__errors', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]), |
||||||
|
}, |
||||||
|
], |
||||||
|
color: 'rgb(226, 192, 61)', |
||||||
|
dataFrameRowIndex: 1, |
||||||
|
id: '1', |
||||||
|
incoming: 1, |
||||||
|
mainStat: { |
||||||
|
config: {}, |
||||||
|
index: 3, |
||||||
|
name: 'mainStat', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([0.1, 0.1, 0.1]), |
||||||
|
}, |
||||||
|
secondaryStat: { |
||||||
|
config: {}, |
||||||
|
index: 4, |
||||||
|
name: 'secondaryStat', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([2, 2, 2]), |
||||||
|
}, |
||||||
|
subTitle: 'service', |
||||||
|
title: 'service:1', |
||||||
|
}, |
||||||
|
{ |
||||||
|
arcSections: [ |
||||||
|
{ |
||||||
|
config: { |
||||||
|
color: { |
||||||
|
fixedColor: 'green', |
||||||
|
}, |
||||||
|
}, |
||||||
|
name: 'arc__success', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]), |
||||||
|
}, |
||||||
|
{ |
||||||
|
config: { |
||||||
|
color: { |
||||||
|
fixedColor: 'red', |
||||||
|
}, |
||||||
|
}, |
||||||
|
name: 'arc__errors', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]), |
||||||
|
}, |
||||||
|
], |
||||||
|
color: 'rgb(226, 192, 61)', |
||||||
|
dataFrameRowIndex: 2, |
||||||
|
id: '2', |
||||||
|
incoming: 2, |
||||||
|
mainStat: { |
||||||
|
config: {}, |
||||||
|
index: 3, |
||||||
|
name: 'mainStat', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([0.1, 0.1, 0.1]), |
||||||
|
}, |
||||||
|
secondaryStat: { |
||||||
|
config: {}, |
||||||
|
index: 4, |
||||||
|
name: 'secondaryStat', |
||||||
|
type: 'number', |
||||||
|
values: new ArrayVector([2, 2, 2]), |
||||||
|
}, |
||||||
|
subTitle: 'service', |
||||||
|
title: 'service:2', |
||||||
|
}, |
||||||
|
]); |
||||||
|
|
||||||
|
expect(edges).toEqual([ |
||||||
|
{ |
||||||
|
dataFrameRowIndex: 0, |
||||||
|
id: '0--1', |
||||||
|
mainStat: '', |
||||||
|
secondaryStat: '', |
||||||
|
source: '0', |
||||||
|
target: '1', |
||||||
|
}, |
||||||
|
{ |
||||||
|
dataFrameRowIndex: 1, |
||||||
|
id: '0--2', |
||||||
|
mainStat: '', |
||||||
|
secondaryStat: '', |
||||||
|
source: '0', |
||||||
|
target: '2', |
||||||
|
}, |
||||||
|
{ |
||||||
|
dataFrameRowIndex: 2, |
||||||
|
id: '1--2', |
||||||
|
mainStat: '', |
||||||
|
secondaryStat: '', |
||||||
|
source: '1', |
||||||
|
target: '2', |
||||||
|
}, |
||||||
|
]); |
||||||
|
|
||||||
|
expect(legend).toEqual([ |
||||||
|
{ |
||||||
|
color: 'green', |
||||||
|
name: 'arc__success', |
||||||
|
}, |
||||||
|
{ |
||||||
|
color: 'red', |
||||||
|
name: 'arc__errors', |
||||||
|
}, |
||||||
|
]); |
||||||
|
}); |
||||||
|
}); |
Loading…
Reference in new issue