diff options
Diffstat (limited to 'app/assets/javascripts/pipelines/components/dag')
6 files changed, 877 insertions, 0 deletions
diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js new file mode 100644 index 00000000000..51b1fb4f4cc --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/constants.js @@ -0,0 +1,10 @@ +/* Error constants */ +export const PARSE_FAILURE = 'parse_failure'; +export const LOAD_FAILURE = 'load_failure'; +export const UNSUPPORTED_DATA = 'unsupported_data'; +export const DEFAULT = 'default'; + +/* Interaction handles */ +export const IS_HIGHLIGHTED = 'dag-highlighted'; +export const LINK_SELECTOR = 'dag-link'; +export const NODE_SELECTOR = 'dag-node'; diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue new file mode 100644 index 00000000000..6e0d23ef87f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -0,0 +1,136 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import DagGraph from './dag_graph.vue'; +import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants'; +import { parseData } from './parsing_utils'; + +export default { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Dag', + components: { + DagGraph, + GlAlert, + GlLink, + GlSprintf, + }, + props: { + graphUrl: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + showFailureAlert: false, + showBetaInfo: true, + failureType: null, + graphData: null, + }; + }, + errorTexts: { + [LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'), + [PARSE_FAILURE]: __('There was an error parsing the data for this graph.'), + [UNSUPPORTED_DATA]: __('A DAG must have two dependent jobs to be visualized on this tab.'), + [DEFAULT]: __('An unknown error occurred while loading this graph.'), + }, + computed: { + betaMessage() { + return __( + 'This feature is currently in beta. We invite you to %{linkStart}give feedback%{linkEnd}.', + ); + }, + failure() { + switch (this.failureType) { + case LOAD_FAILURE: + return { + text: this.$options.errorTexts[LOAD_FAILURE], + variant: 'danger', + }; + case PARSE_FAILURE: + return { + text: this.$options.errorTexts[PARSE_FAILURE], + variant: 'danger', + }; + case UNSUPPORTED_DATA: + return { + text: this.$options.errorTexts[UNSUPPORTED_DATA], + variant: 'info', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + vatiant: 'danger', + }; + } + }, + shouldDisplayGraph() { + return Boolean(!this.showFailureAlert && this.graphData); + }, + }, + mounted() { + const { processGraphData, reportFailure } = this; + + if (!this.graphUrl) { + reportFailure(); + return; + } + + axios + .get(this.graphUrl) + .then(response => { + processGraphData(response.data); + }) + .catch(() => reportFailure(LOAD_FAILURE)); + }, + methods: { + processGraphData(data) { + let parsed; + + try { + parsed = parseData(data.stages); + } catch { + this.reportFailure(PARSE_FAILURE); + return; + } + + if (parsed.links.length < 2) { + this.reportFailure(UNSUPPORTED_DATA); + return; + } + + this.graphData = parsed; + }, + hideAlert() { + this.showFailureAlert = false; + }, + hideBetaInfo() { + this.showBetaInfo = false; + }, + reportFailure(type) { + this.showFailureAlert = true; + this.failureType = type; + }, + }, +}; +</script> +<template> + <div> + <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert"> + {{ failure.text }} + </gl-alert> + + <gl-alert v-if="showBetaInfo" @dismiss="hideBetaInfo"> + <gl-sprintf :message="betaMessage"> + <template #link="{ content }"> + <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/220368" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + <dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue new file mode 100644 index 00000000000..063ec091e4d --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -0,0 +1,299 @@ +<script> +import * as d3 from 'd3'; +import { uniqueId } from 'lodash'; +import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants'; +import { + highlightLinks, + restoreLinks, + toggleLinkHighlight, + togglePathHighlights, +} from './interactions'; +import { getMaxNodes, removeOrphanNodes } from './parsing_utils'; +import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils'; + +export default { + viewOptions: { + baseHeight: 300, + baseWidth: 1000, + minNodeHeight: 60, + nodeWidth: 16, + nodePadding: 25, + paddingForLabels: 100, + labelMargin: 8, + + baseOpacity: 0.8, + containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join( + ' ', + ), + }, + gitLabColorRotation: [ + '#e17223', + '#83ab4a', + '#5772ff', + '#b24800', + '#25d2d2', + '#006887', + '#487900', + '#d84280', + '#3547de', + '#6f3500', + '#006887', + '#275600', + '#b31756', + ], + props: { + graphData: { + type: Object, + required: true, + }, + }, + data() { + return { + color: () => {}, + width: 0, + height: 0, + }; + }, + mounted() { + let countedAndTransformed; + + try { + countedAndTransformed = this.transformData(this.graphData); + } catch { + this.$emit('onFailure', PARSE_FAILURE); + return; + } + + this.drawGraph(countedAndTransformed); + }, + methods: { + addSvg() { + return d3 + .select('.dag-graph-container') + .append('svg') + .attr('viewBox', [0, 0, this.width, this.height]) + .attr('width', this.width) + .attr('height', this.height); + }, + + appendLinks(link) { + return ( + link + .append('path') + .attr('d', (d, i) => createLinkPath(d, i, this.$options.viewOptions.nodeWidth)) + .attr('stroke', ({ gradId }) => `url(#${gradId})`) + .style('stroke-linejoin', 'round') + // minus two to account for the rounded nodes + .attr('stroke-width', ({ width }) => Math.max(1, width - 2)) + .attr('clip-path', ({ clipId }) => `url(#${clipId})`) + ); + }, + + appendLinkInteractions(link) { + return link + .on('mouseover', highlightLinks) + .on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity)) + .on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity)); + }, + + appendNodeInteractions(node) { + return node.on( + 'click', + togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity), + ); + }, + + appendLabelAsForeignObject(d, i, n) { + const currentNode = n[i]; + const { height, wrapperWidth, width, x, y, textAlign } = labelPosition(d, { + ...this.$options.viewOptions, + width: this.width, + }); + + const labelClasses = [ + 'gl-display-flex', + 'gl-pointer-events-none', + 'gl-flex-direction-column', + 'gl-justify-content-center', + 'gl-overflow-wrap-break', + ].join(' '); + + return ( + d3 + .select(currentNode) + .attr('requiredFeatures', 'http://www.w3.org/TR/SVG11/feature#Extensibility') + .attr('height', height) + /* + items with a 'max-content' width will have a wrapperWidth for the foreignObject + */ + .attr('width', wrapperWidth || width) + .attr('x', x) + .attr('y', y) + .classed('gl-overflow-visible', true) + .append('xhtml:div') + .classed(labelClasses, true) + .style('height', height) + .style('width', width) + .style('text-align', textAlign) + .text(({ name }) => name) + ); + }, + + createAndAssignId(datum, field, modifier = '') { + const id = uniqueId(modifier); + /* eslint-disable-next-line no-param-reassign */ + datum[field] = id; + return id; + }, + + createClip(link) { + return link + .append('clipPath') + .attr('id', d => { + return this.createAndAssignId(d, 'clipId', 'dag-clip'); + }) + .append('path') + .attr('d', calculateClip); + }, + + createGradient(link) { + const gradient = link + .append('linearGradient') + .attr('id', d => { + return this.createAndAssignId(d, 'gradId', 'dag-grad'); + }) + .attr('gradientUnits', 'userSpaceOnUse') + .attr('x1', ({ source }) => source.x1) + .attr('x2', ({ target }) => target.x0); + + gradient + .append('stop') + .attr('offset', '0%') + .attr('stop-color', ({ source }) => this.color(source)); + + gradient + .append('stop') + .attr('offset', '100%') + .attr('stop-color', ({ target }) => this.color(target)); + }, + + createLinks(svg, linksData) { + const links = this.generateLinks(svg, linksData); + this.createGradient(links); + this.createClip(links); + this.appendLinks(links); + this.appendLinkInteractions(links); + }, + + createNodes(svg, nodeData) { + const nodes = this.generateNodes(svg, nodeData); + this.labelNodes(svg, nodeData); + this.appendNodeInteractions(nodes); + }, + + drawGraph({ maxNodesPerLayer, linksAndNodes }) { + const { + baseWidth, + baseHeight, + minNodeHeight, + nodeWidth, + nodePadding, + paddingForLabels, + } = this.$options.viewOptions; + + this.width = baseWidth; + this.height = baseHeight + maxNodesPerLayer * minNodeHeight; + this.color = this.initColors(); + + const { links, nodes } = createSankey({ + width: this.width, + height: this.height, + nodeWidth, + nodePadding, + paddingForLabels, + })(linksAndNodes); + + const svg = this.addSvg(); + this.createLinks(svg, links); + this.createNodes(svg, nodes); + }, + + generateLinks(svg, linksData) { + return svg + .append('g') + .attr('fill', 'none') + .attr('stroke-opacity', this.$options.viewOptions.baseOpacity) + .selectAll(`.${LINK_SELECTOR}`) + .data(linksData) + .enter() + .append('g') + .attr('id', d => { + return this.createAndAssignId(d, 'uid', LINK_SELECTOR); + }) + .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true); + }, + + generateNodes(svg, nodeData) { + const { nodeWidth } = this.$options.viewOptions; + + return svg + .append('g') + .selectAll(`.${NODE_SELECTOR}`) + .data(nodeData) + .enter() + .append('line') + .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true) + .attr('id', d => { + return this.createAndAssignId(d, 'uid', NODE_SELECTOR); + }) + .attr('stroke', d => { + const color = this.color(d); + /* eslint-disable-next-line no-param-reassign */ + d.color = color; + return color; + }) + .attr('stroke-width', nodeWidth) + .attr('stroke-linecap', 'round') + .attr('x1', d => Math.floor((d.x1 + d.x0) / 2)) + .attr('x2', d => Math.floor((d.x1 + d.x0) / 2)) + .attr('y1', d => d.y0 + 4) + .attr('y2', d => d.y1 - 4); + }, + + labelNodes(svg, nodeData) { + return svg + .append('g') + .classed('gl-font-sm', true) + .selectAll('text') + .data(nodeData) + .enter() + .append('foreignObject') + .each(this.appendLabelAsForeignObject); + }, + + initColors() { + const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation); + return ({ name }) => colorFn(name); + }, + + transformData(parsed) { + const baseLayout = createSankey()(parsed); + const cleanedNodes = removeOrphanNodes(baseLayout.nodes); + const maxNodesPerLayer = getMaxNodes(cleanedNodes); + + return { + maxNodesPerLayer, + linksAndNodes: { + links: parsed.links, + nodes: cleanedNodes, + }, + }; + }, + }, +}; +</script> +<template> + <div :class="$options.viewOptions.containerClasses" data-testid="dag-graph-container"> + <!-- graph goes here --> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/dag/drawing_utils.js b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js new file mode 100644 index 00000000000..d56addc473f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js @@ -0,0 +1,134 @@ +import * as d3 from 'd3'; +import { sankey, sankeyLeft } from 'd3-sankey'; + +export const calculateClip = ({ y0, y1, source, target, width }) => { + /* + Because large link values can overrun their box, we create a clip path + to trim off the excess in charts that have few nodes per column and are + therefore tall. + + The box is created by + M: moving to outside midpoint of the source node + V: drawing a vertical line to maximum of the bottom link edge or + the lowest edge of the node (can be d.y0 or d.y1 depending on the link's path) + H: drawing a horizontal line to the outside edge of the destination node + V: drawing a vertical line back up to the minimum of the top link edge or + the highest edge of the node (can be d.y0 or d.y1 depending on the link's path) + H: drawing a horizontal line back to the outside edge of the source node + Z: closing the path, back to the start point + */ + + const bottomLinkEdge = Math.max(y1, y0) + width / 2; + const topLinkEdge = Math.min(y0, y1) - width / 2; + + /* eslint-disable @gitlab/require-i18n-strings */ + return ` + M${source.x0}, ${y1} + V${Math.max(bottomLinkEdge, y0, y1)} + H${target.x1} + V${Math.min(topLinkEdge, y0, y1)} + H${source.x0} + Z + `; + /* eslint-enable @gitlab/require-i18n-strings */ +}; + +export const createLinkPath = ({ y0, y1, source, target, width }, idx, nodeWidth) => { + /* + Creates a series of staggered midpoints for the link paths, so they + don't run along one channel and can be distinguished. + + First, get a point staggered by index and link width, modulated by the link box + to find a point roughly between the nodes. + + Then offset it by nodeWidth, so it doesn't run under any nodes at the left. + + Determine where it would overlap at the right. + + Finally, select the leftmost of these options: + - offset from the source node based on index + fudge; + - a fuzzy offset from the right node, using Math.random adds a little blur + - a hard offset from the end node, if random pushes it over + + Then draw a line from the start node to the bottom-most point of the midline + up to the topmost point in that line and then to the middle of the end node + */ + + const xValRaw = source.x1 + (((idx + 1) * width) % (target.x1 - source.x0)); + const xValMin = xValRaw + nodeWidth; + const overlapPoint = source.x1 + (target.x0 - source.x1); + const xValMax = overlapPoint - nodeWidth * 1.4; + + const midPointX = Math.min(xValMin, target.x0 - nodeWidth * 4 * Math.random(), xValMax); + + return d3.line()([ + [(source.x0 + source.x1) / 2, y0], + [midPointX, y0], + [midPointX, y1], + [(target.x0 + target.x1) / 2, y1], + ]); +}; + +/* + createSankey calls the d3 layout to generate the relationships and positioning + values for the nodes and links in the graph. + */ + +export const createSankey = ({ + width = 10, + height = 10, + nodeWidth = 10, + nodePadding = 10, + paddingForLabels = 1, +} = {}) => { + const sankeyGenerator = sankey() + .nodeId(({ name }) => name) + .nodeAlign(sankeyLeft) + .nodeWidth(nodeWidth) + .nodePadding(nodePadding) + .extent([ + [paddingForLabels, paddingForLabels], + [width - paddingForLabels, height - paddingForLabels], + ]); + return ({ nodes, links }) => + sankeyGenerator({ + nodes: nodes.map(d => ({ ...d })), + links: links.map(d => ({ ...d })), + }); +}; + +export const labelPosition = ({ x0, x1, y0, y1 }, viewOptions) => { + const { paddingForLabels, labelMargin, nodePadding, width } = viewOptions; + + const firstCol = x0 <= paddingForLabels; + const lastCol = x1 >= width - paddingForLabels; + + if (firstCol) { + return { + x: 0 + labelMargin, + y: y0, + height: `${y1 - y0}px`, + width: paddingForLabels - 2 * labelMargin, + textAlign: 'right', + }; + } + + if (lastCol) { + return { + x: width - paddingForLabels + labelMargin, + y: y0, + height: `${y1 - y0}px`, + width: paddingForLabels - 2 * labelMargin, + textAlign: 'left', + }; + } + + return { + x: (x1 + x0) / 2, + y: y0 - nodePadding, + height: `${nodePadding}px`, + width: 'max-content', + wrapperWidth: paddingForLabels - 2 * labelMargin, + textAlign: x0 < width / 2 ? 'left' : 'right', + }; +}; diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js new file mode 100644 index 00000000000..c9008730c90 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/interactions.js @@ -0,0 +1,134 @@ +import * as d3 from 'd3'; +import { LINK_SELECTOR, NODE_SELECTOR, IS_HIGHLIGHTED } from './constants'; + +export const highlightIn = 1; +export const highlightOut = 0.2; + +const getCurrent = (idx, collection) => d3.select(collection[idx]); +const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED); +const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`); +const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`); + +const backgroundLinks = selection => selection.style('stroke-opacity', highlightOut); +const backgroundNodes = selection => selection.attr('stroke', '#f2f2f2'); +const foregroundLinks = selection => selection.style('stroke-opacity', highlightIn); +const foregroundNodes = selection => selection.attr('stroke', d => d.color); +const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity); +const renewNodes = selection => selection.attr('stroke', d => d.color); + +const getAllLinkAncestors = node => { + if (node.targetLinks) { + return node.targetLinks.flatMap(n => { + return [n.uid, ...getAllLinkAncestors(n.source)]; + }); + } + + return []; +}; + +const getAllNodeAncestors = node => { + let allNodes = []; + + if (node.targetLinks) { + allNodes = node.targetLinks.flatMap(n => { + return getAllNodeAncestors(n.source); + }); + } + + return [...allNodes, node.uid]; +}; + +export const highlightLinks = (d, idx, collection) => { + const currentLink = getCurrent(idx, collection); + const currentSourceNode = d3.select(`#${d.source.uid}`); + const currentTargetNode = d3.select(`#${d.target.uid}`); + + /* Higlight selected link, de-emphasize others */ + backgroundLinks(getOtherLinks()); + foregroundLinks(currentLink); + + /* Do the same to related nodes */ + backgroundNodes(getNodesNotLive()); + foregroundNodes(currentSourceNode); + foregroundNodes(currentTargetNode); +}; + +const highlightPath = (parentLinks, parentNodes) => { + /* de-emphasize everything else */ + backgroundLinks(getOtherLinks()); + backgroundNodes(getNodesNotLive()); + + /* highlight correct links */ + parentLinks.forEach(id => { + foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true); + }); + + /* highlight correct nodes */ + parentNodes.forEach(id => { + foregroundNodes(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true); + }); +}; + +const restorePath = (parentLinks, parentNodes, baseOpacity) => { + parentLinks.forEach(id => { + renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false); + }); + + parentNodes.forEach(id => { + d3.select(`#${id}`).classed(IS_HIGHLIGHTED, false); + }); + + if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) { + renewLinks(getOtherLinks(), baseOpacity); + renewNodes(getNodesNotLive()); + return; + } + + backgroundLinks(getOtherLinks()); + backgroundNodes(getNodesNotLive()); +}; + +export const restoreLinks = (baseOpacity, d, idx, collection) => { + /* in this case, it has just been clicked */ + if (currentIsLive(idx, collection)) { + return; + } + + /* + if there exist live links, reset to highlight out / pale + otherwise, reset to base + */ + + if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) { + renewLinks(d3.selectAll(`.${LINK_SELECTOR}`), baseOpacity); + renewNodes(d3.selectAll(`.${NODE_SELECTOR}`)); + return; + } + + backgroundLinks(getOtherLinks()); + backgroundNodes(getNodesNotLive()); +}; + +export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => { + if (currentIsLive(idx, collection)) { + restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity); + return; + } + + highlightPath([d.uid], [d.source.uid, d.target.uid]); +}; + +export const togglePathHighlights = (baseOpacity, d, idx, collection) => { + const parentLinks = getAllLinkAncestors(d); + const parentNodes = getAllNodeAncestors(d); + const currentNode = getCurrent(idx, collection); + + /* if this node is already live, make it unlive and reset its path */ + if (currentIsLive(idx, collection)) { + currentNode.classed(IS_HIGHLIGHTED, false); + restorePath(parentLinks, parentNodes, baseOpacity); + return; + } + + highlightPath(parentLinks, parentNodes); +}; diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js new file mode 100644 index 00000000000..3234f80ee91 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js @@ -0,0 +1,164 @@ +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); +}; |