1import { useEffect, useMemo, useRef, useState } from 'react'; 2import { EdgeDatum, EdgeDatumLayout, NodeDatum } from './types'; 3import { Field } from '@grafana/data'; 4import { useNodeLimit } from './useNodeLimit'; 5import useMountedState from 'react-use/lib/useMountedState'; 6import { graphBounds } from './utils'; 7import { createWorker } from './createLayoutWorker'; 8import { useUnmount } from 'react-use'; 9 10export interface Config { 11 linkDistance: number; 12 linkStrength: number; 13 forceX: number; 14 forceXStrength: number; 15 forceCollide: number; 16 tick: number; 17 gridLayout: boolean; 18 sort?: { 19 // Either a arc field or stats field 20 field: Field; 21 ascending: boolean; 22 }; 23} 24 25// Config mainly for the layout but also some other parts like current layout. The layout variables can be changed only 26// if you programmatically enable the config editor (for development only) see ViewControls. These could be moved to 27// panel configuration at some point (apart from gridLayout as that can be switched be user right now.). 28export const defaultConfig: Config = { 29 linkDistance: 150, 30 linkStrength: 0.5, 31 forceX: 2000, 32 forceXStrength: 0.02, 33 forceCollide: 100, 34 tick: 300, 35 gridLayout: false, 36}; 37 38/** 39 * This will return copy of the nods and edges with x,y positions filled in. Also the layout changes source/target props 40 * in edges from string ids to actual nodes. 41 */ 42export function useLayout( 43 rawNodes: NodeDatum[], 44 rawEdges: EdgeDatum[], 45 config: Config = defaultConfig, 46 nodeCountLimit: number, 47 width: number, 48 rootNodeId?: string 49) { 50 const [nodesGraph, setNodesGraph] = useState<NodeDatum[]>([]); 51 const [edgesGraph, setEdgesGraph] = useState<EdgeDatumLayout[]>([]); 52 53 const [loading, setLoading] = useState(false); 54 55 const isMounted = useMountedState(); 56 const layoutWorkerCancelRef = useRef<(() => void) | undefined>(); 57 58 useUnmount(() => { 59 if (layoutWorkerCancelRef.current) { 60 layoutWorkerCancelRef.current(); 61 } 62 }); 63 64 // Also we compute both layouts here. Grid layout should not add much time and we can more easily just cache both 65 // so this should happen only once for a given response data. 66 // 67 // Also important note is that right now this works on all the nodes even if they are not visible. This means that 68 // the node position is stable even when expanding different parts of graph. It seems like a reasonable thing but 69 // implications are that: 70 // - limiting visible nodes count does not have a positive perf effect 71 // - graphs with high node count can seem weird (very sparse or spread out) when we show only some nodes but layout 72 // is done for thousands of nodes but we also do this only once in the graph lifecycle. 73 // We could re-layout this on visible nodes change but this may need smaller visible node limit to keep the perf 74 // (as we would run layout on every click) and also would be very weird without any animation to understand what is 75 // happening as already visible nodes would change positions. 76 useEffect(() => { 77 if (rawNodes.length === 0) { 78 setNodesGraph([]); 79 setEdgesGraph([]); 80 setLoading(false); 81 return; 82 } 83 84 setLoading(true); 85 86 // This is async but as I wanted to still run the sync grid layout and you cannot return promise from effect so 87 // having callback seems ok here. 88 const cancel = defaultLayout(rawNodes, rawEdges, ({ nodes, edges }) => { 89 if (isMounted()) { 90 setNodesGraph(nodes); 91 setEdgesGraph(edges as EdgeDatumLayout[]); 92 setLoading(false); 93 } 94 }); 95 layoutWorkerCancelRef.current = cancel; 96 return cancel; 97 }, [rawNodes, rawEdges, isMounted]); 98 99 // Compute grid separately as it is sync and do not need to be inside effect. Also it is dependant on width while 100 // default layout does not care and we don't want to recalculate that on panel resize. 101 const [nodesGrid, edgesGrid] = useMemo(() => { 102 if (rawNodes.length === 0) { 103 return [[], []]; 104 } 105 106 const rawNodesCopy = rawNodes.map((n) => ({ ...n })); 107 const rawEdgesCopy = rawEdges.map((e) => ({ ...e })); 108 gridLayout(rawNodesCopy, width, config.sort); 109 110 return [rawNodesCopy, rawEdgesCopy as EdgeDatumLayout[]]; 111 }, [config.sort, rawNodes, rawEdges, width]); 112 113 // Limit the nodes so we don't show all for performance reasons. Here we don't compute both at the same time so 114 // changing the layout can trash internal memoization at the moment. 115 const { nodes: nodesWithLimit, edges: edgesWithLimit, markers } = useNodeLimit( 116 config.gridLayout ? nodesGrid : nodesGraph, 117 config.gridLayout ? edgesGrid : edgesGraph, 118 nodeCountLimit, 119 config, 120 rootNodeId 121 ); 122 123 // Get bounds based on current limited number of nodes. 124 const bounds = useMemo(() => graphBounds([...nodesWithLimit, ...(markers || []).map((m) => m.node)]), [ 125 nodesWithLimit, 126 markers, 127 ]); 128 129 return { 130 nodes: nodesWithLimit, 131 edges: edgesWithLimit, 132 markers, 133 bounds, 134 hiddenNodesCount: rawNodes.length - nodesWithLimit.length, 135 loading, 136 }; 137} 138 139/** 140 * Wraps the layout code in a worker as it can take long and we don't want to block the main thread. 141 * Returns a cancel function to terminate the worker. 142 */ 143function defaultLayout( 144 nodes: NodeDatum[], 145 edges: EdgeDatum[], 146 done: (data: { nodes: NodeDatum[]; edges: EdgeDatum[] }) => void 147) { 148 const worker = createWorker(); 149 worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => { 150 for (let i = 0; i < nodes.length; i++) { 151 // These stats needs to be Field class but the data is stringified over the worker boundary 152 event.data.nodes[i] = { 153 ...nodes[i], 154 ...event.data.nodes[i], 155 }; 156 } 157 done(event.data); 158 }; 159 160 worker.postMessage({ 161 nodes: nodes.map((n) => ({ 162 id: n.id, 163 incoming: n.incoming, 164 })), 165 edges, 166 config: defaultConfig, 167 }); 168 169 return () => { 170 worker.terminate(); 171 }; 172} 173 174/** 175 * Set the nodes in simple grid layout sorted by some stat. 176 */ 177function gridLayout( 178 nodes: NodeDatum[], 179 width: number, 180 sort?: { 181 field: Field; 182 ascending: boolean; 183 } 184) { 185 const spacingVertical = 140; 186 const spacingHorizontal = 120; 187 const padding = spacingHorizontal / 2; 188 const perRow = Math.min(Math.floor((width - padding * 2) / spacingVertical), nodes.length); 189 const midPoint = Math.floor(((perRow - 1) * spacingHorizontal) / 2); 190 191 if (sort) { 192 nodes.sort((node1, node2) => { 193 const val1 = sort!.field.values.get(node1.dataFrameRowIndex); 194 const val2 = sort!.field.values.get(node2.dataFrameRowIndex); 195 196 // Lets pretend we don't care about type of the stats for a while (they can be strings) 197 return sort!.ascending ? val1 - val2 : val2 - val1; 198 }); 199 } 200 201 for (const [index, node] of nodes.entries()) { 202 const row = Math.floor(index / perRow); 203 const column = index % perRow; 204 node.x = column * spacingHorizontal - midPoint; 205 node.y = -60 + row * spacingVertical; 206 } 207} 208