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 { EdgeDatum, NodeDatum } from './types'; |
||||
import { css } from '@emotion/css'; |
||||
import { useStyles2 } from '../../themes'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
import { shortenLine } from './utils'; |
||||
|
||||
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 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