Commit 13da0d7f authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '215517-add-hovers-and-clicks' into 'master'

DAG MVC: Interactions

See merge request gitlab-org/gitlab!33832
parents 8c44ca09 166c573f
/* Error constants */
export const PARSE_FAILURE = 'parse_failure'; export const PARSE_FAILURE = 'parse_failure';
export const LOAD_FAILURE = 'load_failure'; export const LOAD_FAILURE = 'load_failure';
export const UNSUPPORTED_DATA = 'unsupported_data'; export const UNSUPPORTED_DATA = 'unsupported_data';
export const DEFAULT = 'default'; export const DEFAULT = 'default';
/* Interaction handles */
export const IS_HIGHLIGHTED = 'dag-highlighted';
export const LINK_SELECTOR = 'dag-link';
export const NODE_SELECTOR = 'dag-node';
<script> <script>
import * as d3 from 'd3'; import * as d3 from 'd3';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import { PARSE_FAILURE } from './constants'; import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants';
import {
highlightLinks,
restoreLinks,
toggleLinkHighlight,
togglePathHighlights,
} from './interactions';
import { getMaxNodes, removeOrphanNodes } from './parsing_utils'; import { getMaxNodes, removeOrphanNodes } from './parsing_utils';
import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils'; import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
...@@ -16,11 +21,7 @@ export default { ...@@ -16,11 +21,7 @@ export default {
paddingForLabels: 100, paddingForLabels: 100,
labelMargin: 8, labelMargin: 8,
// can plausibly applied through CSS instead, TBD
baseOpacity: 0.8, baseOpacity: 0.8,
highlightIn: 1,
highlightOut: 0.2,
containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join( containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join(
' ', ' ',
), ),
...@@ -88,6 +89,20 @@ export default { ...@@ -88,6 +89,20 @@ export default {
); );
}, },
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) { appendLabelAsForeignObject(d, i, n) {
const currentNode = n[i]; const currentNode = n[i];
const { height, wrapperWidth, width, x, y, textAlign } = labelPosition(d, { const { height, wrapperWidth, width, x, y, textAlign } = labelPosition(d, {
...@@ -163,15 +178,17 @@ export default { ...@@ -163,15 +178,17 @@ export default {
}, },
createLinks(svg, linksData) { createLinks(svg, linksData) {
const link = this.generateLinks(svg, linksData); const links = this.generateLinks(svg, linksData);
this.createGradient(link); this.createGradient(links);
this.createClip(link); this.createClip(links);
this.appendLinks(link); this.appendLinks(links);
this.appendLinkInteractions(links);
}, },
createNodes(svg, nodeData) { createNodes(svg, nodeData) {
this.generateNodes(svg, nodeData); const nodes = this.generateNodes(svg, nodeData);
this.labelNodes(svg, nodeData); this.labelNodes(svg, nodeData);
this.appendNodeInteractions(nodes);
}, },
drawGraph({ maxNodesPerLayer, linksAndNodes }) { drawGraph({ maxNodesPerLayer, linksAndNodes }) {
...@@ -202,37 +219,39 @@ export default { ...@@ -202,37 +219,39 @@ export default {
}, },
generateLinks(svg, linksData) { generateLinks(svg, linksData) {
const linkContainerName = 'dag-link';
return svg return svg
.append('g') .append('g')
.attr('fill', 'none') .attr('fill', 'none')
.attr('stroke-opacity', this.$options.viewOptions.baseOpacity) .attr('stroke-opacity', this.$options.viewOptions.baseOpacity)
.selectAll(`.${linkContainerName}`) .selectAll(`.${LINK_SELECTOR}`)
.data(linksData) .data(linksData)
.enter() .enter()
.append('g') .append('g')
.attr('id', d => { .attr('id', d => {
return this.createAndAssignId(d, 'uid', linkContainerName); return this.createAndAssignId(d, 'uid', LINK_SELECTOR);
}) })
.classed(`${linkContainerName} gl-cursor-pointer`, true); .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true);
}, },
generateNodes(svg, nodeData) { generateNodes(svg, nodeData) {
const nodeContainerName = 'dag-node';
const { nodeWidth } = this.$options.viewOptions; const { nodeWidth } = this.$options.viewOptions;
return svg return svg
.append('g') .append('g')
.selectAll(`.${nodeContainerName}`) .selectAll(`.${NODE_SELECTOR}`)
.data(nodeData) .data(nodeData)
.enter() .enter()
.append('line') .append('line')
.classed(`${nodeContainerName} gl-cursor-pointer`, true) .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true)
.attr('id', d => { .attr('id', d => {
return this.createAndAssignId(d, 'uid', nodeContainerName); 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', this.color)
.attr('stroke-width', nodeWidth) .attr('stroke-width', nodeWidth)
.attr('stroke-linecap', 'round') .attr('stroke-linecap', 'round')
.attr('x1', d => Math.floor((d.x1 + d.x0) / 2)) .attr('x1', d => Math.floor((d.x1 + d.x0) / 2))
......
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);
};
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants';
import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions';
import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import { removeOrphanNodes } from '~/pipelines/components/dag/parsing_utils'; import { removeOrphanNodes } from '~/pipelines/components/dag/parsing_utils';
import { parsedData } from './mock_data'; import { parsedData } from './mock_data';
...@@ -8,8 +10,8 @@ describe('The DAG graph', () => { ...@@ -8,8 +10,8 @@ describe('The DAG graph', () => {
let wrapper; let wrapper;
const getGraph = () => wrapper.find('.dag-graph-container > svg'); const getGraph = () => wrapper.find('.dag-graph-container > svg');
const getAllLinks = () => wrapper.findAll('.dag-link'); const getAllLinks = () => wrapper.findAll(`.${LINK_SELECTOR}`);
const getAllNodes = () => wrapper.findAll('.dag-node'); const getAllNodes = () => wrapper.findAll(`.${NODE_SELECTOR}`);
const getAllLabels = () => wrapper.findAll('foreignObject'); const getAllLabels = () => wrapper.findAll('foreignObject');
const createComponent = (propsData = {}) => { const createComponent = (propsData = {}) => {
...@@ -94,4 +96,123 @@ describe('The DAG graph', () => { ...@@ -94,4 +96,123 @@ describe('The DAG graph', () => {
}); });
}); });
}); });
describe('interactions', () => {
const strokeOpacity = opacity => `stroke-opacity: ${opacity};`;
const baseOpacity = () => wrapper.vm.$options.viewOptions.baseOpacity;
describe('links', () => {
const liveLink = () => getAllLinks().at(4);
const otherLink = () => getAllLinks().at(1);
describe('on hover', () => {
it('sets the link opacity to baseOpacity and background links to 0.2', () => {
liveLink().trigger('mouseover');
expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn));
expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut));
});
it('reverts the styles on mouseout', () => {
liveLink().trigger('mouseover');
liveLink().trigger('mouseout');
expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity()));
expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity()));
});
});
describe('on click', () => {
describe('toggles link liveness', () => {
it('turns link on', () => {
liveLink().trigger('click');
expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn));
expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut));
});
it('turns link off on second click', () => {
liveLink().trigger('click');
liveLink().trigger('click');
expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity()));
expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity()));
});
});
it('the link remains live even after mouseout', () => {
liveLink().trigger('click');
liveLink().trigger('mouseout');
expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn));
expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut));
});
it('preserves state when multiple links are toggled on and off', () => {
const anotherLiveLink = () => getAllLinks().at(2);
liveLink().trigger('click');
anotherLiveLink().trigger('click');
expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn));
expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(highlightIn));
expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut));
anotherLiveLink().trigger('click');
expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn));
expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(highlightOut));
expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut));
liveLink().trigger('click');
expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity()));
expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(baseOpacity()));
expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity()));
});
});
});
describe('nodes', () => {
const liveNode = () => getAllNodes().at(10);
const anotherLiveNode = () => getAllNodes().at(5);
const nodesNotHighlighted = () => getAllNodes().filter(n => !n.classes(IS_HIGHLIGHTED));
const linksNotHighlighted = () => getAllLinks().filter(n => !n.classes(IS_HIGHLIGHTED));
const nodesHighlighted = () => getAllNodes().filter(n => n.classes(IS_HIGHLIGHTED));
const linksHighlighted = () => getAllLinks().filter(n => n.classes(IS_HIGHLIGHTED));
describe('on click', () => {
it('highlights the clicked node and predecessors', () => {
liveNode().trigger('click');
expect(nodesNotHighlighted().length < getAllNodes().length).toBe(true);
expect(linksNotHighlighted().length < getAllLinks().length).toBe(true);
linksHighlighted().wrappers.forEach(link => {
expect(link.attributes('style')).toBe(strokeOpacity(highlightIn));
});
nodesHighlighted().wrappers.forEach(node => {
expect(node.attributes('stroke')).not.toBe('#f2f2f2');
});
linksNotHighlighted().wrappers.forEach(link => {
expect(link.attributes('style')).toBe(strokeOpacity(highlightOut));
});
nodesNotHighlighted().wrappers.forEach(node => {
expect(node.attributes('stroke')).toBe('#f2f2f2');
});
});
it('toggles path off on second click', () => {
liveNode().trigger('click');
liveNode().trigger('click');
expect(nodesNotHighlighted().length).toBe(getAllNodes().length);
expect(linksNotHighlighted().length).toBe(getAllLinks().length);
});
it('preserves state when multiple nodes are toggled on and off', () => {
anotherLiveNode().trigger('click');
liveNode().trigger('click');
anotherLiveNode().trigger('click');
expect(nodesNotHighlighted().length < getAllNodes().length).toBe(true);
expect(linksNotHighlighted().length < getAllLinks().length).toBe(true);
});
});
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment