Passed
Push — master ( 7b3678...eca4bd )
by
unknown
02:40
created

DetailsDrawHelpers.ts ➔ createRootNode   A

Complexity

Conditions 1

Size

Total Lines 28
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 26
dl 0
loc 28
rs 9.256
c 0
b 0
f 0
cc 1
1
import { NodeSelection, TreeNode } from '../../components/types';
2
import {
3
    selectDetailsContainerDiv,
4
    selectDetailsExitButtonWrapper,
5
    selectDetailsRootNodeContainer,
6
    selectDetailsViewContainer,
7
    selectDetailsZoom,
8
} from './Selectors';
9
import { ElementColors, ElementIds, FAST_TRANSITION_DURATION, LabelColors, TextColors } from '../AppConsts';
10
import { hierarchy, HierarchyPointLink, HierarchyPointNode, tree } from 'd3-hierarchy';
11
import { create, linkHorizontal, symbol, symbolCross, zoom, zoomIdentity } from 'd3';
12
import { createLabelPath, createZoom } from './DrawHelpers';
13
import { changeZoom, getTextDimensions } from './GraphHelpers';
14
15
export function createDetailsViewContainer(width: number, height: number) {
16
    const containerWidth = width * 4;
17
    const containerHeight = height * 4;
18
19
    const container = selectDetailsContainerDiv()
20
        .style('display', 'none')
21
        .style('opacity', 0)
22
        .append('svg')
23
        .attr('id', ElementIds.DETAILS_VIEW_CONTAINER)
24
        .attr('x', 0)
25
        .attr('y', 0)
26
        .attr('width', containerWidth)
27
        .attr('height', containerHeight)
28
        .attr('viewBox', `0 0 ${width} ${height}`)
29
        .attr('preserveAspectRatio', 'xMidYMid meet');
30
    container
31
        .append('rect')
32
        .attr('width', width)
33
        .attr('height', height)
34
        .attr('fill', ElementColors.DETAILS_BACKGROUND);
35
    container
36
        .append('g')
37
        .attr('id', ElementIds.DETAILS_EXIT_BUTTON)
38
        .attr('cursor', 'pointer')
39
        .append('rect')
40
        .attr('transform', 'translate(5,5)')
41
        .attr('width', 10)
42
        .attr('height', 10)
43
        .attr('fill', ElementColors.DETAILS_BACKGROUND);
44
    selectDetailsExitButtonWrapper()
45
        .append('path')
46
        .attr('transform', 'translate(10,10) rotate(45)')
47
        .attr(
48
            'd',
49
            symbol()
50
                .type(symbolCross)
51
                .size(20)
52
        );
53
    createZoom(container, ElementIds.DETAILS_ZOOM);
54
}
55
56
function resetZoomPosition() {
57
    const detailsContainerDiv = selectDetailsContainerDiv().node();
58
    const { width, height } = detailsContainerDiv ? detailsContainerDiv.getBoundingClientRect() : { width: 0, height: 0 };
59
60
    const container = selectDetailsViewContainer();
61
62
    container.call(
63
        zoom<SVGSVGElement, unknown>().on('zoom', changeZoom(ElementIds.DETAILS_ZOOM)).transform,
64
        // rough center screen on diagram's root node
65
        zoomIdentity.translate(-width / 5.3, -height / 2.65)
66
    );
67
}
68
69
// these magic numbers(-30, -36.5) are dependant of how node labels are painted relative to label text
70
const labelPathWidthOffset = -30;
71
const labelPathHeightOffset = -36.5;
72
73
async function createRootNode(
74
    container: NodeSelection<any>,
75
    viewboxWidth: number,
76
    viexboxHeight: number,
77
    rootNodeName: string,
78
    isConsumer: boolean,
79
    isProvider: boolean
80
) {
81
    const nodeContainer = container
82
        .append('svg')
83
        .attr('id', ElementIds.DETAILS_ROOT_NODE_CONTAINER)
84
        .attr('font-size', 15)
85
        // hard-coded magic numbers that translates root node to position of root of the tree graphs
86
        .attr('viewBox', `-${viewboxWidth / 3.2} -${viexboxHeight / 2} ${viewboxWidth} ${viexboxHeight}`)
87
        .attr('preserveAspectRatio', 'xMidYMid meet');
88
    const label = nodeContainer.append('path').attr('fill', LabelColors.FOCUSED);
89
    const text = nodeContainer
90
        .append('text')
91
        .attr('text-anchor', 'middle')
92
        .attr('fill', TextColors.HIGHLIGHTED)
93
        .text(rootNodeName);
94
    await delayPromise();
95
    const { height, x, y } = getTextDimensions(text.node()) || { height: 0, x: 0, y: 0 };
96
    const labelPath = createLabelPath.call(label.node(), isConsumer, isProvider);
97
    label.attr('d', labelPath).attr('transform', `translate(${x + labelPathWidthOffset}, ${y + labelPathHeightOffset})`);
98
    // center text vertically on label
99
    text.attr('y', y + height + 1);
100
}
101
102
function delayPromise(delay: number = 0) {
103
    return new Promise(resolve => setTimeout(resolve, delay));
104
}
105
106
interface TreeStructure {
107
    name: string;
108
    children?: TreeStructure[];
109
}
110
111
type TreeNodeWithVisitedFlag = TreeNode & { isVisited?: boolean };
112
113
function mapNodeToTreeStructure(node: TreeNodeWithVisitedFlag, linksType: 'consumers' | 'providers'): TreeStructure {
114
    node.isVisited = true;
115
    const unvisitedLinks = node[linksType].filter((linkedNode: TreeNodeWithVisitedFlag) => !linkedNode.isVisited && linkedNode[linksType]);
116
    const children = unvisitedLinks.map(nestedNode => mapNodeToTreeStructure(nestedNode, linksType));
117
    node.isVisited = undefined;
118
    return { name: node.name, children };
119
}
120
121
const VERTICAL_DISTANCE_BETWEEN_NODES = 40;
122
const HORIZONTAL_DISTANCE_BETWEEN_NODES = 300;
123
124
const NODE_NEIGHBOURS_SEPARATION_MULTIPLIER = 1;
125
const NODE_NORMAL_SEPARATION_MULTIPLIER = 4;
126
127
function createTree(data: TreeStructure) {
128
    const node = hierarchy(data);
129
    return tree<TreeStructure>()
130
        .nodeSize([VERTICAL_DISTANCE_BETWEEN_NODES, HORIZONTAL_DISTANCE_BETWEEN_NODES])
131
        .separation((node1, node2) =>
132
            node1.parent === node2.parent ? NODE_NEIGHBOURS_SEPARATION_MULTIPLIER : NODE_NORMAL_SEPARATION_MULTIPLIER
133
        )(node);
134
}
135
136
function getRootYPosition(data: HierarchyPointNode<TreeStructure>) {
137
    let x0 = Infinity;
138
    data.each(node => {
139
        if (node.x < x0) x0 = node.x;
140
    });
141
    return VERTICAL_DISTANCE_BETWEEN_NODES - x0;
142
}
143
144
function createDiagram(
145
    tree: HierarchyPointNode<TreeStructure>,
146
    containerWidth: number,
147
    containerHeight: number,
148
    rootNodeYOffset: number,
149
    drawToLeft: boolean = false
150
) {
151
    if (!tree.children || !tree.children.length) {
152
        return null;
153
    }
154
155
    const diagramWidth = containerWidth / 2;
156
    const diagramXOffset = -diagramWidth / 8;
157
    const diagramYOffset = -containerHeight / 2;
158
159
    const svg = create('svg').attr('viewBox', `${diagramXOffset} ${diagramYOffset + rootNodeYOffset} ${diagramWidth} ${containerHeight}`);
160
161
    const g = svg
162
        .append('g')
163
        .attr('font-size', 15)
164
        .attr('transform', transformDiagramElement(0, rootNodeYOffset, drawToLeft));
165
166
    g.append('g')
167
        .attr('fill', 'none')
168
        .attr('stroke', ElementColors.DETAILS_LINK)
169
        .attr('stroke-width', 2)
170
        .selectAll('path')
171
        .data(tree.links())
172
        .join('path')
173
        .attr(
174
            'd',
175
            linkHorizontal<HierarchyPointLink<TreeStructure>, HierarchyPointNode<TreeStructure>>()
176
                .x(d => d.y)
177
                .y(d => d.x)
178
        );
179
180
    const node = g
181
        .append('g')
182
        .attr('stroke-linejoin', 'round')
183
        .attr('stroke-width', 3)
184
        .selectAll('g')
185
        .data(tree.descendants())
186
        .join('g')
187
        .attr('transform', d => transformDiagramElement(d.y, d.x, drawToLeft));
188
189
    node.append('text')
190
        .attr('dy', '0.31em')
191
        .attr('x', 0)
192
        .attr('text-anchor', 'middle')
193
        .style('background-color', '#ffffff')
194
        .text(node => node.data.name)
195
        .clone(true)
196
        .lower()
197
        .attr('stroke-width', 4)
198
        .attr('stroke', 'white');
199
200
    return svg;
201
}
202
203
function transformDiagramElement(xOffset: number, yOffset: number, drawToLeft: boolean) {
204
    return `translate(${xOffset},${yOffset}) ${drawToLeft ? 'rotate(180)' : ''}`;
205
}
206
207
async function createDiagrams(
208
    container: ReturnType<typeof selectDetailsZoom>,
209
    consumersData: TreeStructure,
210
    providersData: TreeStructure,
211
    width: number,
212
    height: number
213
) {
214
    const rootNodeName = consumersData.name || providersData.name;
215
    consumersData.name = '';
216
    providersData.name = '';
217
    const consumersTree = createTree(consumersData);
218
    const providersTree = createTree(providersData);
219
220
    const consumersRootYPosition = getRootYPosition(consumersTree);
221
    const providersRootYPosition = getRootYPosition(providersTree);
222
223
    const rootNodeYOffset = Math.max(consumersRootYPosition, providersRootYPosition);
224
225
    const consumersDiagram = createDiagram(consumersTree, width, height, rootNodeYOffset, true);
226
    const providersDiagram = createDiagram(providersTree, width, height, rootNodeYOffset);
227
228
    await createRootNode(container, width, height, rootNodeName, Boolean(providersDiagram), Boolean(consumersDiagram));
229
230
    const detailsRootNodeContainer = selectDetailsRootNodeContainer();
231
    const detailsRootNodeBBox = detailsRootNodeContainer.node()?.getBBox() || { width: 0, x: 0 };
232
233
    const providersDiagramNode = providersDiagram?.node();
234
    const consumersDiagramNode = consumersDiagram?.node();
235
236
    const xOffsetFromRootNodeCenter = detailsRootNodeBBox.width / 7;
237
238
    if (providersDiagramNode) {
239
        providersDiagram?.attr('x', xOffsetFromRootNodeCenter);
240
        container.append<SVGSVGElement>(() => providersDiagramNode);
241
    }
242
243
    if (consumersDiagramNode) {
244
        consumersDiagram?.attr('x', -xOffsetFromRootNodeCenter);
245
        container.append<SVGSVGElement>(() => consumersDiagramNode);
246
    }
247
248
    detailsRootNodeContainer.raise();
249
}
250
251
export function initializeDetailsView(node: TreeNode) {
252
    const consumerNodes = mapNodeToTreeStructure(node, 'consumers');
253
    const providerNodes = mapNodeToTreeStructure(node, 'providers');
254
255
    const detailsViewContainer = selectDetailsViewContainer();
256
    const width = Number(detailsViewContainer.attr('width'));
257
    const height = Number(detailsViewContainer.attr('height'));
258
259
    const detailsZoom = selectDetailsZoom();
260
    createDiagrams(detailsZoom, consumerNodes, providerNodes, width, height);
261
    switchDetailsVisibility();
262
263
    resetZoomPosition();
264
}
265
266
export function shutdownDetailsView() {
267
    switchDetailsVisibility();
268
    deleteDiagrams();
269
}
270
271
function deleteDiagrams() {
272
    selectDetailsZoom()
273
        .transition()
274
        .selectAll('*')
275
        .remove();
276
}
277
278
export function switchDetailsVisibility() {
279
    const container = selectDetailsContainerDiv();
280
    const isVisible = container.style('display') === 'block';
281
    if (isVisible) {
282
        container
283
            .transition()
284
            .duration(FAST_TRANSITION_DURATION)
285
            .style('opacity', 0)
286
            .transition()
287
            .style('display', 'none');
288
    } else {
289
        container
290
            .style('display', 'block')
291
            .transition()
292
            .duration(FAST_TRANSITION_DURATION)
293
            .style('opacity', 1);
294
    }
295
}
296