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