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

View File

@@ -19,7 +19,6 @@ import {
// getRandomInt, // getRandomInt,
} from './utils/helpers'; } from './utils/helpers';
import { import {
MESH_FORCE_LAYOUT,
DEFAULT_RADIUS, DEFAULT_RADIUS,
DEFAULT_NODE_COLOR, DEFAULT_NODE_COLOR,
DEFAULT_NODE_HIGHLIGHT_COLOR, DEFAULT_NODE_HIGHLIGHT_COLOR,
@@ -39,10 +38,12 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
const [isNodeSelected, setIsNodeSelected] = useState(false); const [isNodeSelected, setIsNodeSelected] = useState(false);
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [nodeDetail, setNodeDetail] = useState(null); const [nodeDetail, setNodeDetail] = useState(null);
const [simulationProgress, setSimulationProgress] = useState(null);
const history = useHistory(); const history = useHistory();
// const data = generateRandomNodes(getRandomInt(4, 50)); // const data = generateRandomNodes(getRandomInt(4, 50));
const draw = () => { const draw = () => {
setShowZoomControls(false);
const width = getWidth(SELECTOR); const width = getWidth(SELECTOR);
const height = getHeight(SELECTOR); const height = getHeight(SELECTOR);
@@ -58,136 +59,164 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
const graph = data; const graph = data;
const simulation = d3 /* WEB WORKER */
.forceSimulation() const worker = new Worker(
.nodes(graph.nodes) new URL('./utils/workers/simulationWorker.js', import.meta.url)
.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));
const link = mesh worker.postMessage({
.append('g') nodes: graph.nodes,
.attr('class', `links`) links: graph.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');
});
const node = mesh worker.onmessage = function handleWorkerEvent(event) {
.append('g') switch (event.data.type) {
.attr('class', 'nodes') case 'tick':
.attr('data-cy', 'nodes') return ticked(event.data);
.selectAll('g') case 'end':
.data(graph.nodes) return ended(event.data);
.enter() default:
.append('g') return false;
.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 circles function ticked({ progress }) {
node const calculatedPercent = Math.round(progress * 100);
.append('circle') setSimulationProgress(calculatedPercent);
.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);
// node type labels function ended({ nodes, links }) {
node // Remove loading screen
.append('text') d3.select('.simulation-loader').style('visibility', 'hidden');
.text((d) => renderNodeType(d.node_type)) setShowZoomControls(true);
.attr('text-anchor', 'middle') // Center the mesh
.attr('dominant-baseline', 'central') const simulation = d3
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR); .forceSimulation(nodes)
.force('center', d3.forceCenter(width / 2, height / 2));
// node hostname labels simulation.tick();
const hostNames = node.append('g'); // Add links
hostNames mesh
.append('text') .append('g')
.text((d) => renderLabelText(d.node_state, d.hostname)) .attr('class', `links`)
.attr('class', 'placeholder') .attr('data-cy', 'links')
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR) .selectAll('line')
.attr('text-anchor', 'middle') .data(links)
.attr('y', 40) .enter()
.each(function calculateLabelWidth() { .append('line')
// 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
.attr('x1', (d) => d.source.x) .attr('x1', (d) => d.source.x)
.attr('y1', (d) => d.source.y) .attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x) .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})`); // node circles
calculateAlphaDecay(simulation.alpha(), simulation.alphaMin(), 20); 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) { // node hostname labels
setTimeout(() => { const hostNames = node.append('g');
svg hostNames
.select(`circle.id-${n.id}`) .append('text')
.attr('fill', DEFAULT_NODE_HIGHLIGHT_COLOR); .attr('x', (d) => d.x)
const immediate = graph.links.filter( .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) => (l) =>
n.hostname === l.source.hostname || n.hostname === l.target.hostname n.hostname === l.source.hostname || n.hostname === l.target.hostname
); );
@@ -195,51 +224,27 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
svg svg
.selectAll(`.link-${s.index}`) .selectAll(`.link-${s.index}`)
.transition() .transition()
.style('stroke', '#0066CC') .style('stroke', '#ccc')
.style('stroke-width', '3px'); .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) { function highlightSelected(n) {
setShowZoomControls(false); if (svg.select(`circle.id-${n.id}`).attr('stroke-width') !== null) {
const decayPercentage = Math.min((aMin / a) * 100); // toggle rings
if (decayPercentage >= x) { svg.select(`circle.id-${n.id}`).attr('stroke-width', null);
d3.select('.simulation-loader').style('visibility', 'hidden'); // show default empty state of tooltip
setShowZoomControls(true); 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) redirectToDetailsPage(selectedNode, history)
} }
/> />
<Loader className="simulation-loader" /> <Loader className="simulation-loader" progress={simulationProgress} />
</div> </div>
); );
} }

View File

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