Passed
Pull Request — master (#22)
by
unknown
03:12 queued 49s
created

DetailsDrawHelpers.ts ➔ createDiagrams   B

Complexity

Conditions 8

Size

Total Lines 44
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Importance

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