dashboard/shell/components/ForceDirectedTreeChart/index.vue

572 lines
16 KiB
Vue

<script>
import * as d3 from 'd3';
import { STATES } from '@shell/plugins/dashboard-store/resource-class';
import { BadgeState } from '@components/BadgeState';
export default {
name: 'ForceDirectedTreeChart',
components: { BadgeState },
props: {
data: {
type: [Array, Object],
required: true
},
fdcConfig: {
type: Object,
required: true
}
},
async fetch() {
this.canViewChart = await this.fdcConfig.checkSchemaPermissions(this.$store);
if (this.canViewChart) {
// set watcher for the chart data
this.dataWatcher = this.$watch(this.fdcConfig.watcherProp, (newValue) => {
this.watcherFunction(newValue);
}, {
deep: true,
immediate: true
});
}
},
data() {
return {
canViewChart: null,
dataWatcher: undefined,
parsedInfo: undefined,
root: undefined,
allNodesData: undefined,
allLinks: undefined,
rootNode: undefined,
node: undefined,
link: undefined,
svg: undefined,
zoom: undefined,
simulation: undefined,
isChartFirstRendered: false,
isChartFirstRenderAnimationFinished: false,
moreInfo: {}
};
},
methods: {
watcherFunction(newValue) {
if (newValue?.length) {
if (!this.isChartFirstRendered) {
this.parsedInfo = this.fdcConfig.parseData(this.data);
// set details info and set active state for node
this.setDetailsInfo(this.parsedInfo, false);
this.parsedInfo.active = true;
// render and update chart
this.renderChart();
this.updateChart(true, true);
this.isChartFirstRendered = true;
// here we just look for changes in the status of the nodes and update them accordingly
} else {
const parsedInfo = this.fdcConfig.parseData(this.data);
const flattenedData = this.flatten(parsedInfo);
let hasStatusChange = false;
flattenedData.forEach((item) => {
const index = this.allNodesData.findIndex((nodeData) => item.matchingId === nodeData.data.matchingId);
// apply status change to each node
if (index > -1 && this.allNodesData[index].data.state !== item.state) {
this.allNodesData[index].data.state = item.state;
this.allNodesData[index].data.stateLabel = item.stateLabel;
this.allNodesData[index].data.stateColor = item.stateColor;
hasStatusChange = true;
// if node is selected (active), update details info
if (this.allNodesData[index].data.active) {
this.setDetailsInfo(this.allNodesData[index].data, false);
}
}
});
if (hasStatusChange) {
this.updateChart(false, false);
}
}
}
},
renderChart() {
this.zoom = d3.zoom().scaleExtent([1 / 8, 16]).on('zoom', this.zoomed);
const transform = d3.zoomIdentity.scale(1).translate(0, 0);
this.rootNode = this.svg.append('g')
.attr('class', 'root-node');
this.svg.call(this.zoom);
this.svg.call(this.zoom.transform, transform);
this.simulation = d3.forceSimulation()
.force('charge', d3.forceManyBody().strength(this.fdcConfig.simulationParams.fdcStrength).distanceMax(this.fdcConfig.simulationParams.fdcDistanceMax))
.force('collision', d3.forceCollide(this.fdcConfig.simulationParams.fdcForceCollide))
.force('center', d3.forceCenter( this.fdcConfig.chartWidth / 2, this.fdcConfig.chartHeight / 2 ))
.alphaDecay(this.fdcConfig.simulationParams.fdcAlphaDecay)
.on('tick', this.ticked)
.on('end', () => {
if (!this.isChartFirstRenderAnimationFinished) {
this.zoomFit();
this.isChartFirstRenderAnimationFinished = true;
}
});
},
updateChart(isStartingData, isSettingNodesAndLinks) {
if (isStartingData) {
this.root = d3.hierarchy(this.parsedInfo);
}
if (isSettingNodesAndLinks) {
this.allNodesData = this.flatten(this.root);
this.allLinks = this.root.links();
}
this.link = this.rootNode
.selectAll('.link')
.data(this.allLinks, (d) => {
return d.target.id;
});
this.link.exit().remove();
const linkEnter = this.link
.enter()
.append('line')
.attr('class', 'link')
.style('opacity', '0.2')
.style('stroke-width', 4);
this.link = linkEnter.merge(this.link);
this.node = this.rootNode
.selectAll('.node')
.data(this.allNodesData, (d) => {
return d.id;
})
// this is where we define which prop changes with any data update (status color)
.attr('class', this.mainNodeClass);
this.node.exit().remove();
// define the node styling and function
const nodeEnter = this.node
.enter()
.append('g')
.attr('class', this.mainNodeClass)
.style('opacity', 1)
.on('click', (ev, d) => {
this.setDetailsInfo(d.data, true);
})
.call(d3.drag()
.on('start', this.dragStarted)
.on('drag', this.dragging)
.on('end', this.dragEnded));
// draw status circle (inherits color from main node)
nodeEnter.append('circle')
.attr('r', this.setNodeRadius);
nodeEnter.append('circle')
.attr('r', (d) => this.setNodeRadius(d) - 5)
.attr('class', 'node-hover-layer');
nodeEnter.append('svg')
.html((d) => this.fdcConfig.fetchNodeIcon(d))
.attr('x', this.nodeImagePosition)
.attr('y', this.nodeImagePosition)
.attr('height', this.nodeImageSize)
.attr('width', this.nodeImageSize);
this.node = nodeEnter.merge(this.node);
this.simulation.nodes(this.allNodesData);
this.simulation.force('link', d3.forceLink()
.id((d) => d.id)
.distance(100)
.links(this.allLinks)
);
},
mainNodeClass(d) {
const lowerCaseStatus = d.data?.state ? d.data.state.toLowerCase() : 'unkown_status';
const defaultClassArray = ['node'];
if (d?.data?.muteStatus) {
defaultClassArray.push(`node-default-fill`);
} else if (STATES[lowerCaseStatus] && STATES[lowerCaseStatus].color) {
defaultClassArray.push(`node-${ STATES[lowerCaseStatus].color }`);
}
// node active (clicked)
if (d.data?.active) {
defaultClassArray.push('active');
}
// here we extend the node classes (different chart types)
const extendedClassArray = this.fdcConfig.extendNodeClass(d).concat(defaultClassArray);
return extendedClassArray.join(' ');
},
setNodeRadius(d) {
const { radius } = this.fdcConfig.nodeDimensions(d);
return radius;
},
nodeImageSize(d) {
const { size } = this.fdcConfig.nodeDimensions(d);
return size;
},
nodeImagePosition(d) {
const { position } = this.fdcConfig.nodeDimensions(d);
return position;
},
setDetailsInfo(data, toUpdate) {
// get the data to be displayed on info box, per each different chart
this.moreInfo = Object.assign([], this.fdcConfig.infoDetails(data));
// update to the chart is needed when active state changes
if (toUpdate) {
this.allNodesData.forEach((item, i) => {
if (item.data.matchingId === data.matchingId) {
this.allNodesData[i].data.active = true;
} else {
this.allNodesData[i].data.active = false;
}
});
this.updateChart(false, false);
}
},
zoomFit() {
const rootNode = d3.select('.root-node');
if (!rootNode?.node()) {
return;
}
const paddingBuffer = 30;
const chartDimentions = rootNode.node().getBoundingClientRect();
const chartCoordinates = rootNode.node().getBBox();
const parent = rootNode.node().parentElement;
const fullWidth = parent.clientWidth;
const fullHeight = parent.clientHeight;
const width = chartDimentions.width;
const height = chartDimentions.height;
const midX = chartCoordinates.x + width / 2;
const midY = chartCoordinates.y + height / 2;
if (width === 0 || height === 0) {
return;
} // nothing to fit
const scale = 1 / Math.max(width / (fullWidth - paddingBuffer), height / (fullHeight - paddingBuffer));
const translate = [fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY];
const transform = d3.zoomIdentity
.translate(translate[0], translate[1])
.scale(scale);
// this update the cached zoom state!!!!! very important so that any transforms from user interaction keep this base!
this.svg.call(this.zoom.transform, transform);
},
ticked() {
this.link
.attr('x1', (d) => {
return d.source.x;
})
.attr('y1', (d) => {
return d.source.y;
})
.attr('x2', (d) => {
return d.target.x;
})
.attr('y2', (d) => {
return d.target.y;
});
this.node
.attr('transform', (d) => {
return `translate(${ d.x }, ${ d.y })`;
});
},
dragStarted(ev, d) {
if (!ev.active) {
this.simulation.alphaTarget(0.3).restart();
}
d.fx = d.x;
d.fy = d.y;
},
dragging(ev, d) {
d.fx = ev.x;
d.fy = ev.y;
},
dragEnded(ev, d) {
if (!ev.active) {
this.simulation.alphaTarget(0);
}
d.fx = undefined;
d.fy = undefined;
},
zoomed(ev) {
this.rootNode.attr('transform', ev.transform);
},
flatten(root) {
const nodes = [];
let i = 0;
function recurse(node) {
if (node.children) {
node.children.forEach(recurse);
}
if (!node.id) {
node.id = ++i;
} else {
++i;
}
nodes.push(node);
}
recurse(root);
return nodes;
}
},
mounted() {
// start by appending SVG to define height of chart area
this.svg = d3.select('#tree').append('svg')
.attr('viewBox', `0 0 ${ this.fdcConfig.chartWidth } ${ this.fdcConfig.chartHeight }`)
.attr('preserveAspectRatio', 'none');
},
unmounted() {
this.dataWatcher();
},
};
</script>
<template>
<div>
<div
class="chart-container"
data-testid="resource-graph"
>
<!-- loading status container -->
<div
v-if="!isChartFirstRenderAnimationFinished"
class="loading-container"
>
<p v-if="canViewChart === false">
{{ t('graph.noPermissions') }}
</p>
<p v-else-if="!isChartFirstRendered">
{{ t('graph.loading') }}
</p>
<p v-else-if="!isChartFirstRenderAnimationFinished">
{{ t('graph.rendering') }}
</p>
<i
v-if="canViewChart !== false"
class="mt-10 icon-spinner icon-spin"
/>
</div>
<!-- main div for svg container -->
<div id="tree" />
<!-- info box -->
<div class="more-info-container">
<div class="more-info">
<table>
<tr
v-for="(item, i) in moreInfo"
:key="i"
>
<td
v-if="item.type !== 'single-error'"
:class="{'align-middle': item.type === 'state-badge'}"
>
<span class="more-info-item-label">{{ t(item.labelKey) }}:</span>
</td>
<!-- title template -->
<td v-if="item.type === 'title-link'">
<span v-if="item.valueObj.detailLocation">
<router-link
:to="item.valueObj.detailLocation"
>
{{ item.valueObj.label }}
</router-link>
</span>
<span v-else>{{ item.valueObj.label }}</span>
</td>
<!-- state-badge template -->
<td
v-else-if="item.type === 'state-badge'"
class="align-middle"
>
<span>
<BadgeState
:color="`bg-${item.valueObj.stateColor}`"
:label="item.valueObj.stateLabel"
class="state-bagde"
/>
</span>
</td>
<!-- single-error template -->
<td
v-if="item.type === 'single-error'"
class="single-error"
colspan="2"
>
<p>{{ item.value }}</p>
</td>
<td v-else-if="item.type === 'resource-type'">
{{ t(`typeLabel."${ item.valueKey }"`, { count: 1 }) }}
</td>
<!-- default template -->
<td v-else>
{{ item.value }}
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.chart-container {
display: flex;
background-color: var(--body-bg);
position: relative;
border: 1px solid var(--border);
border-radius: var(--border-radius);
min-height: 100px;
.loading-container {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: var(--border-radius);
background-color: var(--body-bg);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
i {
font-size: 24px;
}
}
#tree {
width: 70%;
height: fit-content;
svg {
margin-top: 3px;
}
.link {
stroke: var(--darker);
}
.node {
cursor: pointer;
&.active {
.node-hover-layer {
display: block;
}
}
&.bundle.active > circle {
transform: scale(1.35);
}
&.bundledeployment.active > circle {
transform: scale(1.6);
}
&.node-default-fill > circle {
transform: scale(1.2);
fill: var(--muted);
}
&.node-success > circle {
fill: var(--success);
}
&.node-info > circle {
fill: var(--info);
}
&.node-warning > circle {
fill: var(--warning);
}
&.node-error > circle {
fill: var(--error);
}
.node-hover-layer {
stroke: var(--body-bg);
stroke-width: 2;
display: none;
}
}
}
.more-info-container {
width: 30%;
position: relative;
border-left: 1px solid var(--border);
background-color: var(--body-bg);
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
overflow: hidden;
.more-info {
position: absolute;
top: 0;
left: 0;
right:0;
bottom:0;
width: 100%;
padding: 20px;
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
overflow-y: auto;
table {
td {
vertical-align: top;
padding-bottom: 10px;
&.align-middle {
vertical-align: middle;
}
}
.more-info-item-label {
color: var(--darker);
margin-right: 8px;
}
.single-error {
color: var(--error);
}
p {
line-height: 1.5em;
}
}
}
}
}
</style>