diff options
Diffstat (limited to 'subprojects/frontend/src/graph/DotGraphVisualizer.tsx')
-rw-r--r-- | subprojects/frontend/src/graph/DotGraphVisualizer.tsx | 162 |
1 files changed, 162 insertions, 0 deletions
diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx new file mode 100644 index 00000000..eec72a7d --- /dev/null +++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx | |||
@@ -0,0 +1,162 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import * as d3 from 'd3'; | ||
8 | import { type Graphviz, graphviz } from 'd3-graphviz'; | ||
9 | import type { BaseType, Selection } from 'd3-selection'; | ||
10 | import { reaction, type IReactionDisposer } from 'mobx'; | ||
11 | import { observer } from 'mobx-react-lite'; | ||
12 | import { useCallback, useRef, useState } from 'react'; | ||
13 | |||
14 | import getLogger from '../utils/getLogger'; | ||
15 | |||
16 | import type GraphStore from './GraphStore'; | ||
17 | import GraphTheme from './GraphTheme'; | ||
18 | import { FitZoomCallback } from './ZoomCanvas'; | ||
19 | import dotSource from './dotSource'; | ||
20 | import postProcessSvg from './postProcessSVG'; | ||
21 | |||
22 | const LOG = getLogger('graph.DotGraphVisualizer'); | ||
23 | |||
24 | function ptToPx(pt: number): number { | ||
25 | return (pt * 4) / 3; | ||
26 | } | ||
27 | |||
28 | function DotGraphVisualizer({ | ||
29 | graph, | ||
30 | fitZoom, | ||
31 | transitionTime, | ||
32 | animateThreshold, | ||
33 | }: { | ||
34 | graph: GraphStore; | ||
35 | fitZoom?: FitZoomCallback; | ||
36 | transitionTime?: number; | ||
37 | animateThreshold?: number; | ||
38 | }): JSX.Element { | ||
39 | const transitionTimeOrDefault = | ||
40 | transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; | ||
41 | const animateThresholdOrDefault = | ||
42 | animateThreshold ?? DotGraphVisualizer.defaultProps.animateThreshold; | ||
43 | const disposerRef = useRef<IReactionDisposer | undefined>(); | ||
44 | const graphvizRef = useRef< | ||
45 | Graphviz<BaseType, unknown, null, undefined> | undefined | ||
46 | >(); | ||
47 | const [animate, setAnimate] = useState(true); | ||
48 | |||
49 | const setElement = useCallback( | ||
50 | (element: HTMLDivElement | null) => { | ||
51 | if (disposerRef.current !== undefined) { | ||
52 | disposerRef.current(); | ||
53 | disposerRef.current = undefined; | ||
54 | } | ||
55 | if (graphvizRef.current !== undefined) { | ||
56 | // `@types/d3-graphviz` does not contain the signature for the `destroy` method. | ||
57 | (graphvizRef.current as unknown as { destroy(): void }).destroy(); | ||
58 | graphvizRef.current = undefined; | ||
59 | } | ||
60 | if (element !== null) { | ||
61 | element.replaceChildren(); | ||
62 | const renderer = graphviz(element) as Graphviz< | ||
63 | BaseType, | ||
64 | unknown, | ||
65 | null, | ||
66 | undefined | ||
67 | >; | ||
68 | renderer.keyMode('id'); | ||
69 | ['TRUE', 'UNKNOWN', 'ERROR'].forEach((icon) => | ||
70 | renderer.addImage(`#${icon}`, 16, 16), | ||
71 | ); | ||
72 | renderer.zoom(false); | ||
73 | renderer.tweenPrecision('5%'); | ||
74 | renderer.tweenShapes(false); | ||
75 | renderer.convertEqualSidedPolygons(false); | ||
76 | if (animate) { | ||
77 | const transition = () => | ||
78 | d3 | ||
79 | .transition() | ||
80 | .duration(transitionTimeOrDefault) | ||
81 | .ease(d3.easeCubic); | ||
82 | /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument, | ||
83 | @typescript-eslint/no-explicit-any -- | ||
84 | Workaround for error in `@types/d3-graphviz`. | ||
85 | */ | ||
86 | renderer.transition(transition as any); | ||
87 | } else { | ||
88 | renderer.tweenPaths(false); | ||
89 | } | ||
90 | let newViewBox = { width: 0, height: 0 }; | ||
91 | renderer.onerror(LOG.error.bind(LOG)); | ||
92 | renderer.on( | ||
93 | 'postProcessSVG', | ||
94 | // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. | ||
95 | ( | ||
96 | svgSelection: Selection<SVGSVGElement, unknown, BaseType, unknown>, | ||
97 | ) => { | ||
98 | const svg = svgSelection.node(); | ||
99 | if (svg !== null) { | ||
100 | postProcessSvg(svg); | ||
101 | newViewBox = { | ||
102 | width: ptToPx(svg.viewBox.baseVal.width), | ||
103 | height: ptToPx(svg.viewBox.baseVal.height), | ||
104 | }; | ||
105 | } else { | ||
106 | // Do not trigger fit zoom. | ||
107 | newViewBox = { width: 0, height: 0 }; | ||
108 | } | ||
109 | }, | ||
110 | ); | ||
111 | renderer.on('renderEnd', () => { | ||
112 | // `d3-graphviz` uses `<title>` elements for traceability, | ||
113 | // so we only remove them after the rendering is finished. | ||
114 | d3.select(element).selectAll('title').remove(); | ||
115 | }); | ||
116 | if (fitZoom !== undefined) { | ||
117 | if (animate) { | ||
118 | renderer.on('transitionStart', () => fitZoom(newViewBox)); | ||
119 | } else { | ||
120 | renderer.on('end', () => fitZoom(false)); | ||
121 | } | ||
122 | } | ||
123 | disposerRef.current = reaction( | ||
124 | () => dotSource(graph), | ||
125 | (result) => { | ||
126 | if (result === undefined) { | ||
127 | return; | ||
128 | } | ||
129 | const [source, size] = result; | ||
130 | // Disable tweening for large graphs to improve performance. | ||
131 | // See https://github.com/magjac/d3-graphviz/issues/232#issuecomment-1157555213 | ||
132 | const newAnimate = size < animateThresholdOrDefault; | ||
133 | if (animate === newAnimate) { | ||
134 | renderer.renderDot(source); | ||
135 | } else { | ||
136 | setAnimate(newAnimate); | ||
137 | } | ||
138 | }, | ||
139 | { fireImmediately: true }, | ||
140 | ); | ||
141 | graphvizRef.current = renderer; | ||
142 | } | ||
143 | }, | ||
144 | [ | ||
145 | graph, | ||
146 | fitZoom, | ||
147 | transitionTimeOrDefault, | ||
148 | animateThresholdOrDefault, | ||
149 | animate, | ||
150 | ], | ||
151 | ); | ||
152 | |||
153 | return <GraphTheme ref={setElement} />; | ||
154 | } | ||
155 | |||
156 | DotGraphVisualizer.defaultProps = { | ||
157 | fitZoom: undefined, | ||
158 | transitionTime: 250, | ||
159 | animateThreshold: 100, | ||
160 | }; | ||
161 | |||
162 | export default observer(DotGraphVisualizer); | ||