aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/graph/DotGraphVisualizer.tsx')
-rw-r--r--subprojects/frontend/src/graph/DotGraphVisualizer.tsx162
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
7import * as d3 from 'd3';
8import { type Graphviz, graphviz } from 'd3-graphviz';
9import type { BaseType, Selection } from 'd3-selection';
10import { reaction, type IReactionDisposer } from 'mobx';
11import { observer } from 'mobx-react-lite';
12import { useCallback, useRef, useState } from 'react';
13
14import getLogger from '../utils/getLogger';
15
16import type GraphStore from './GraphStore';
17import GraphTheme from './GraphTheme';
18import { FitZoomCallback } from './ZoomCanvas';
19import dotSource from './dotSource';
20import postProcessSvg from './postProcessSVG';
21
22const LOG = getLogger('graph.DotGraphVisualizer');
23
24function ptToPx(pt: number): number {
25 return (pt * 4) / 3;
26}
27
28function 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
156DotGraphVisualizer.defaultProps = {
157 fitZoom: undefined,
158 transitionTime: 250,
159 animateThreshold: 100,
160};
161
162export default observer(DotGraphVisualizer);