layout.ts 6.8 KB

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