165 lines
4.4 KiB
JavaScript
165 lines
4.4 KiB
JavaScript
|
import { uniqWith, isEqual } from 'lodash';
|
||
|
|
||
|
/*
|
||
|
The following functions are the main engine in transforming the data as
|
||
|
received from the endpoint into the format the d3 graph expects.
|
||
|
|
||
|
Input is of the form:
|
||
|
[stages]
|
||
|
stages: {name, groups}
|
||
|
groups: [{ name, size, jobs }]
|
||
|
name is a group name; in the case that the group has one job, it is
|
||
|
also the job name
|
||
|
size is the number of parallel jobs
|
||
|
jobs: [{ name, needs}]
|
||
|
job name is either the same as the group name or group x/y
|
||
|
|
||
|
Output is of the form:
|
||
|
{ nodes: [node], links: [link] }
|
||
|
node: { name, category }, + unused info passed through
|
||
|
link: { source, target, value }, with source & target being node names
|
||
|
and value being a constant
|
||
|
|
||
|
We create nodes, create links, and then dedupe the links, so that in the case where
|
||
|
job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link
|
||
|
from job 1 to job 2 then another from job 2 to job 4.
|
||
|
|
||
|
CREATE NODES
|
||
|
stage.name -> node.category
|
||
|
stage.group.name -> node.name (this is the group name if there are parallel jobs)
|
||
|
stage.group.jobs -> node.jobs
|
||
|
stage.group.size -> node.size
|
||
|
|
||
|
CREATE LINKS
|
||
|
stages.groups.name -> target
|
||
|
stages.groups.needs.each -> source (source is the name of the group, not the parallel job)
|
||
|
10 -> value (constant)
|
||
|
*/
|
||
|
|
||
|
export const createNodes = data => {
|
||
|
return data.flatMap(({ groups, name }) => {
|
||
|
return groups.map(group => {
|
||
|
return { ...group, category: name };
|
||
|
});
|
||
|
});
|
||
|
};
|
||
|
|
||
|
export const createNodeDict = nodes => {
|
||
|
return nodes.reduce((acc, node) => {
|
||
|
const newNode = {
|
||
|
...node,
|
||
|
needs: node.jobs.map(job => job.needs || []).flat(),
|
||
|
};
|
||
|
|
||
|
if (node.size > 1) {
|
||
|
node.jobs.forEach(job => {
|
||
|
acc[job.name] = newNode;
|
||
|
});
|
||
|
}
|
||
|
|
||
|
acc[node.name] = newNode;
|
||
|
return acc;
|
||
|
}, {});
|
||
|
};
|
||
|
|
||
|
export const createNodesStructure = data => {
|
||
|
const nodes = createNodes(data);
|
||
|
const nodeDict = createNodeDict(nodes);
|
||
|
|
||
|
return { nodes, nodeDict };
|
||
|
};
|
||
|
|
||
|
export const makeLinksFromNodes = (nodes, nodeDict) => {
|
||
|
const constantLinkValue = 10; // all links are the same weight
|
||
|
return nodes
|
||
|
.map(group => {
|
||
|
return group.jobs.map(job => {
|
||
|
if (!job.needs) {
|
||
|
return [];
|
||
|
}
|
||
|
|
||
|
return job.needs.map(needed => {
|
||
|
return {
|
||
|
source: nodeDict[needed]?.name,
|
||
|
target: group.name,
|
||
|
value: constantLinkValue,
|
||
|
};
|
||
|
});
|
||
|
});
|
||
|
})
|
||
|
.flat(2);
|
||
|
};
|
||
|
|
||
|
export const getAllAncestors = (nodes, nodeDict) => {
|
||
|
const needs = nodes
|
||
|
.map(node => {
|
||
|
return nodeDict[node].needs || '';
|
||
|
})
|
||
|
.flat()
|
||
|
.filter(Boolean);
|
||
|
|
||
|
if (needs.length) {
|
||
|
return [...needs, ...getAllAncestors(needs, nodeDict)];
|
||
|
}
|
||
|
|
||
|
return [];
|
||
|
};
|
||
|
|
||
|
export const filterByAncestors = (links, nodeDict) =>
|
||
|
links.filter(({ target, source }) => {
|
||
|
/*
|
||
|
|
||
|
for every link, check out it's target
|
||
|
for every target, get the target node's needs
|
||
|
then drop the current link source from that list
|
||
|
|
||
|
call a function to get all ancestors, recursively
|
||
|
is the current link's source in the list of all parents?
|
||
|
then we drop this link
|
||
|
|
||
|
*/
|
||
|
const targetNode = target;
|
||
|
const targetNodeNeeds = nodeDict[targetNode].needs;
|
||
|
const targetNodeNeedsMinusSource = targetNodeNeeds.filter(need => need !== source);
|
||
|
|
||
|
const allAncestors = getAllAncestors(targetNodeNeedsMinusSource, nodeDict);
|
||
|
return !allAncestors.includes(source);
|
||
|
});
|
||
|
|
||
|
export const parseData = data => {
|
||
|
const { nodes, nodeDict } = createNodesStructure(data);
|
||
|
const allLinks = makeLinksFromNodes(nodes, nodeDict);
|
||
|
const filteredLinks = filterByAncestors(allLinks, nodeDict);
|
||
|
const links = uniqWith(filteredLinks, isEqual);
|
||
|
|
||
|
return { nodes, links };
|
||
|
};
|
||
|
|
||
|
/*
|
||
|
The number of nodes in the most populous generation drives the height of the graph.
|
||
|
*/
|
||
|
|
||
|
export const getMaxNodes = nodes => {
|
||
|
const counts = nodes.reduce((acc, { layer }) => {
|
||
|
if (!acc[layer]) {
|
||
|
acc[layer] = 0;
|
||
|
}
|
||
|
|
||
|
acc[layer] += 1;
|
||
|
|
||
|
return acc;
|
||
|
}, []);
|
||
|
|
||
|
return Math.max(...counts);
|
||
|
};
|
||
|
|
||
|
/*
|
||
|
Because we cannot know if a node is part of a relationship until after we
|
||
|
generate the links with createSankey, this function is used after the first call
|
||
|
to find nodes that have no relations.
|
||
|
*/
|
||
|
|
||
|
export const removeOrphanNodes = sankeyfiedNodes => {
|
||
|
return sankeyfiedNodes.filter(node => node.sourceLinks.length || node.targetLinks.length);
|
||
|
};
|