Offload simulation calculation to web worker.

This commit is contained in:
Kia Lam 2022-02-23 18:54:18 -08:00
parent 7fbab6760e
commit fd135caed5
4 changed files with 217 additions and 171 deletions

View File

@ -4,11 +4,11 @@ import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
EmptyState as PFEmptyState,
EmptyStateIcon,
Progress,
ProgressMeasureLocation,
Text,
TextContent,
TextVariants,
Spinner,
} from '@patternfly/react-core';
import { TopologyIcon as PFTopologyIcon } from '@patternfly/react-icons';
@ -23,10 +23,16 @@ const TopologyIcon = styled(PFTopologyIcon)`
fill: #6a6e73;
`;
const ContentLoading = ({ className }) => (
const ContentLoading = ({ className, progress }) => (
<EmptyState variant="full" className={className}>
<TopologyIcon />
<TextContent>
<Progress
value={progress}
measureLocation={ProgressMeasureLocation.inside}
aria-label={t`content-loading-in-progress`}
style={{ margin: '20px' }}
/>
<TextContent style={{ margin: '20px' }}>
<Text
component={TextVariants.small}
style={{ fontWeight: 'bold', color: 'black' }}
@ -34,7 +40,6 @@ const ContentLoading = ({ className }) => (
{t`Please wait until the topology view is populated...`}
</Text>
</TextContent>
<EmptyStateIcon variant="container" component={Spinner} />
</EmptyState>
);

View File

@ -19,7 +19,6 @@ import {
// getRandomInt,
} from './utils/helpers';
import {
MESH_FORCE_LAYOUT,
DEFAULT_RADIUS,
DEFAULT_NODE_COLOR,
DEFAULT_NODE_HIGHLIGHT_COLOR,
@ -39,10 +38,12 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
const [isNodeSelected, setIsNodeSelected] = useState(false);
const [selectedNode, setSelectedNode] = useState(null);
const [nodeDetail, setNodeDetail] = useState(null);
const [simulationProgress, setSimulationProgress] = useState(null);
const history = useHistory();
// const data = generateRandomNodes(getRandomInt(4, 50));
const draw = () => {
setShowZoomControls(false);
const width = getWidth(SELECTOR);
const height = getHeight(SELECTOR);
@ -58,136 +59,164 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
const graph = data;
const simulation = d3
.forceSimulation()
.nodes(graph.nodes)
.force(
'charge',
d3.forceManyBody().strength(MESH_FORCE_LAYOUT.defaultForceStrength)
)
.force(
'link',
d3.forceLink(graph.links).id((d) => d.hostname)
)
.force(
'collide',
d3.forceCollide(MESH_FORCE_LAYOUT.defaultCollisionFactor)
)
.force('forceX', d3.forceX(MESH_FORCE_LAYOUT.defaultForceX))
.force('forceY', d3.forceY(MESH_FORCE_LAYOUT.defaultForceY))
.force('center', d3.forceCenter(width / 2, height / 2));
/* WEB WORKER */
const worker = new Worker(
new URL('./utils/workers/simulationWorker.js', import.meta.url)
);
const link = mesh
.append('g')
.attr('class', `links`)
.attr('data-cy', 'links')
.selectAll('line')
.data(graph.links)
.enter()
.append('line')
.attr('class', (_, i) => `link-${i}`)
.attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`)
.style('fill', 'none')
.style('stroke', '#ccc')
.style('stroke-width', '2px')
.attr('pointer-events', 'none')
.on('mouseover', function showPointer() {
d3.select(this).transition().style('cursor', 'pointer');
});
worker.postMessage({
nodes: graph.nodes,
links: graph.links,
});
const node = mesh
.append('g')
.attr('class', 'nodes')
.attr('data-cy', 'nodes')
.selectAll('g')
.data(graph.nodes)
.enter()
.append('g')
.on('mouseenter', function handleNodeHover(_, d) {
d3.select(this).transition().style('cursor', 'pointer');
highlightSiblings(d);
})
.on('mouseleave', (_, d) => {
deselectSiblings(d);
})
.on('click', (_, d) => {
setNodeDetail(d);
highlightSelected(d);
});
worker.onmessage = function handleWorkerEvent(event) {
switch (event.data.type) {
case 'tick':
return ticked(event.data);
case 'end':
return ended(event.data);
default:
return false;
}
};
// node circles
node
.append('circle')
.attr('r', DEFAULT_RADIUS)
.attr('class', (d) => d.node_type)
.attr('class', (d) => `id-${d.id}`)
.attr('fill', DEFAULT_NODE_COLOR)
.attr('stroke', DEFAULT_NODE_LABEL_TEXT_COLOR);
function ticked({ progress }) {
const calculatedPercent = Math.round(progress * 100);
setSimulationProgress(calculatedPercent);
}
// node type labels
node
.append('text')
.text((d) => renderNodeType(d.node_type))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR);
// node hostname labels
const hostNames = node.append('g');
hostNames
.append('text')
.text((d) => renderLabelText(d.node_state, d.hostname))
.attr('class', 'placeholder')
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
.attr('text-anchor', 'middle')
.attr('y', 40)
.each(function calculateLabelWidth() {
// eslint-disable-next-line react/no-this-in-sfc
const bbox = this.getBBox();
// eslint-disable-next-line react/no-this-in-sfc
d3.select(this.parentNode)
.append('rect')
.attr('x', bbox.x)
.attr('y', bbox.y)
.attr('width', bbox.width)
.attr('height', bbox.height)
.attr('rx', 8)
.attr('ry', 8)
.style('fill', (d) => renderStateColor(d.node_state));
});
svg.selectAll('text.placeholder').remove();
hostNames
.append('text')
.text((d) => renderLabelText(d.node_state, d.hostname))
.attr('font-size', DEFAULT_FONT_SIZE)
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
.attr('text-anchor', 'middle')
.attr('y', 38);
simulation.nodes(graph.nodes).on('tick', ticked);
simulation.force('link').links(graph.links);
function ticked() {
d3.select('.simulation-loader').style('visibility', 'visible');
link
function ended({ nodes, links }) {
// Remove loading screen
d3.select('.simulation-loader').style('visibility', 'hidden');
setShowZoomControls(true);
// Center the mesh
const simulation = d3
.forceSimulation(nodes)
.force('center', d3.forceCenter(width / 2, height / 2));
simulation.tick();
// Add links
mesh
.append('g')
.attr('class', `links`)
.attr('data-cy', 'links')
.selectAll('line')
.data(links)
.enter()
.append('line')
.attr('x1', (d) => d.source.x)
.attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x)
.attr('y2', (d) => d.target.y);
.attr('y2', (d) => d.target.y)
.attr('class', (_, i) => `link-${i}`)
.attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`)
.style('fill', 'none')
.style('stroke', '#ccc')
.style('stroke-width', '2px')
.attr('pointer-events', 'none')
.on('mouseover', function showPointer() {
d3.select(this).transition().style('cursor', 'pointer');
});
// add nodes
const node = mesh
.append('g')
.attr('class', 'nodes')
.attr('data-cy', 'nodes')
.selectAll('g')
.data(nodes)
.enter()
.append('g')
.on('mouseenter', function handleNodeHover(_, d) {
d3.select(this).transition().style('cursor', 'pointer');
highlightSiblings(d);
})
.on('mouseleave', (_, d) => {
deselectSiblings(d);
})
.on('click', (_, d) => {
setNodeDetail(d);
highlightSelected(d);
});
node.attr('transform', (d) => `translate(${d.x},${d.y})`);
calculateAlphaDecay(simulation.alpha(), simulation.alphaMin(), 20);
}
// node circles
node
.append('circle')
.attr('r', DEFAULT_RADIUS)
.attr('cx', (d) => d.x)
.attr('cy', (d) => d.y)
.attr('class', (d) => d.node_type)
.attr('class', (d) => `id-${d.id}`)
.attr('fill', DEFAULT_NODE_COLOR)
.attr('stroke', DEFAULT_NODE_LABEL_TEXT_COLOR);
svg.call(zoom);
// node type labels
node
.append('text')
.text((d) => renderNodeType(d.node_type))
.attr('x', (d) => d.x)
.attr('y', (d) => d.y)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR);
function highlightSiblings(n) {
setTimeout(() => {
svg
.select(`circle.id-${n.id}`)
.attr('fill', DEFAULT_NODE_HIGHLIGHT_COLOR);
const immediate = graph.links.filter(
// node hostname labels
const hostNames = node.append('g');
hostNames
.append('text')
.attr('x', (d) => d.x)
.attr('y', (d) => d.y + 40)
.text((d) => renderLabelText(d.node_state, d.hostname))
.attr('class', 'placeholder')
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
.attr('text-anchor', 'middle')
.each(function calculateLabelWidth() {
// eslint-disable-next-line react/no-this-in-sfc
const bbox = this.getBBox();
// eslint-disable-next-line react/no-this-in-sfc
d3.select(this.parentNode)
.append('rect')
.attr('x', bbox.x)
.attr('y', bbox.y)
.attr('width', bbox.width)
.attr('height', bbox.height)
.attr('rx', 8)
.attr('ry', 8)
.style('fill', (d) => renderStateColor(d.node_state));
});
svg.selectAll('text.placeholder').remove();
hostNames
.append('text')
.attr('x', (d) => d.x)
.attr('y', (d) => d.y + 38)
.text((d) => renderLabelText(d.node_state, d.hostname))
.attr('font-size', DEFAULT_FONT_SIZE)
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
.attr('text-anchor', 'middle');
svg.call(zoom);
function highlightSiblings(n) {
setTimeout(() => {
svg
.select(`circle.id-${n.id}`)
.attr('fill', DEFAULT_NODE_HIGHLIGHT_COLOR);
const immediate = links.filter(
(l) =>
n.hostname === l.source.hostname ||
n.hostname === l.target.hostname
);
immediate.forEach((s) => {
svg
.selectAll(`.link-${s.index}`)
.transition()
.style('stroke', '#0066CC')
.style('stroke-width', '3px');
});
}, 0);
}
function deselectSiblings(n) {
svg.select(`circle.id-${n.id}`).attr('fill', DEFAULT_NODE_COLOR);
const immediate = links.filter(
(l) =>
n.hostname === l.source.hostname || n.hostname === l.target.hostname
);
@ -195,51 +224,27 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
svg
.selectAll(`.link-${s.index}`)
.transition()
.style('stroke', '#0066CC')
.style('stroke-width', '3px');
.style('stroke', '#ccc')
.style('stroke-width', '2px');
});
}, 0);
}
function deselectSiblings(n) {
svg.select(`circle.id-${n.id}`).attr('fill', DEFAULT_NODE_COLOR);
const immediate = graph.links.filter(
(l) =>
n.hostname === l.source.hostname || n.hostname === l.target.hostname
);
immediate.forEach((s) => {
svg
.selectAll(`.link-${s.index}`)
.transition()
.style('stroke', '#ccc')
.style('stroke-width', '2px');
});
}
function highlightSelected(n) {
if (svg.select(`circle.id-${n.id}`).attr('stroke-width') !== null) {
// toggle rings
svg.select(`circle.id-${n.id}`).attr('stroke-width', null);
// show default empty state of tooltip
setIsNodeSelected(false);
setSelectedNode(null);
return;
}
svg.selectAll('circle').attr('stroke-width', null);
svg
.select(`circle.id-${n.id}`)
.attr('stroke-width', '5px')
.attr('stroke', '#D2D2D2');
setIsNodeSelected(true);
setSelectedNode(n);
}
function calculateAlphaDecay(a, aMin, x) {
setShowZoomControls(false);
const decayPercentage = Math.min((aMin / a) * 100);
if (decayPercentage >= x) {
d3.select('.simulation-loader').style('visibility', 'hidden');
setShowZoomControls(true);
function highlightSelected(n) {
if (svg.select(`circle.id-${n.id}`).attr('stroke-width') !== null) {
// toggle rings
svg.select(`circle.id-${n.id}`).attr('stroke-width', null);
// show default empty state of tooltip
setIsNodeSelected(false);
setSelectedNode(null);
return;
}
svg.selectAll('circle').attr('stroke-width', null);
svg
.select(`circle.id-${n.id}`)
.attr('stroke-width', '5px')
.attr('stroke', '#D2D2D2');
setIsNodeSelected(true);
setSelectedNode(n);
}
}
};
@ -267,7 +272,7 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
redirectToDetailsPage(selectedNode, history)
}
/>
<Loader className="simulation-loader" />
<Loader className="simulation-loader" progress={simulationProgress} />
</div>
);
}

View File

@ -3,8 +3,9 @@ export const PARENTSELECTOR = '.mesh-svg';
export const CHILDSELECTOR = '.mesh';
export const DEFAULT_RADIUS = 16;
export const MESH_FORCE_LAYOUT = {
defaultCollisionFactor: DEFAULT_RADIUS * 2 + 20,
defaultForceStrength: -30,
defaultCollisionFactor: DEFAULT_RADIUS * 2 + 30,
defaultForceStrength: -50,
defaultForceBody: 15,
defaultForceX: 0,
defaultForceY: 0,
};

View File

@ -0,0 +1,35 @@
import * as d3 from 'd3';
import { MESH_FORCE_LAYOUT } from '../../constants';
onmessage = function calculateLayout({ data: { nodes, links } }) {
const simulation = d3
.forceSimulation(nodes)
.force(
'charge',
d3
.forceManyBody(MESH_FORCE_LAYOUT.defaultForceBody)
.strength(MESH_FORCE_LAYOUT.defaultForceStrength)
)
.force(
'link',
d3.forceLink(links).id((d) => d.hostname)
)
.force('collide', d3.forceCollide(MESH_FORCE_LAYOUT.defaultCollisionFactor))
.force('forceX', d3.forceX(MESH_FORCE_LAYOUT.defaultForceX))
.force('forceY', d3.forceY(MESH_FORCE_LAYOUT.defaultForceY))
.stop();
for (
let i = 0,
n = Math.ceil(
Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())
);
i < n;
++i
) {
postMessage({ type: 'tick', progress: i / n });
simulation.tick();
}
postMessage({ type: 'end', nodes, links });
};