Commit c3bd6b21 authored by James Long's avatar James Long Committed by Commit Bot

Enhance visibility of edge directions in dependency visualization

A few UI controls have been added with the aim of making edge directions
easier to see in the visualization.
* Curve checkbox: Determines if edges should be curved to the right of
their direction.
* Color on hover checkbox: Determines if the edge colors should only be
shown when hovering on a node that touches those edges.
* Color scheme dropdown: Various preset color schemes determined to work
well.

Bug: 1106107
Change-Id: I15585251ad1ef3eef4b16e1ce536a1b1f527868c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2314818
Commit-Queue: James Long <yjlong@google.com>
Reviewed-by: default avatarMohamed Heikal <mheikal@chromium.org>
Reviewed-by: default avatarSamuel Huang <huangs@chromium.org>
Cr-Commit-Position: refs/heads/master@{#791430}
parent 301f2eb6
......@@ -120,12 +120,15 @@ let D3GraphData;
/**
* Generates and returns a unique edge ID from its source/target GraphNode IDs.
*
* This is used as an SVG element ID, so it must adhere to ID requirements
* (unique, non-empty, no whitespace).
* @param {string} sourceId The ID of the source node.
* @param {string} targetId The ID of the target node.
* @return {string} The ID uniquely identifying the edge source -> target.
*/
function getEdgeIdFromNodes(sourceId, targetId) {
return `${sourceId} > ${targetId}`;
return `${sourceId}>${targetId}`;
}
/** A directed graph. */
......
......@@ -3,13 +3,40 @@
// found in the LICENSE file.
import {GraphNode, D3GraphData} from './graph_model.js';
import {DisplaySettingsData, GraphEdgeColor} from './page_model.js';
import * as d3 from 'd3';
// The unique HTML IDs for the SVG's `defs`
// (https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs)
const DEF_IDS = {
GRAPH_ARROWHEAD: 'graph-arrowhead',
// The perpendicular distance from the center of an edge to place the control
// point for a quadratic Bezier curve.
const EDGE_CURVE_OFFSET = {
CURVED: 30,
STRAIGHT: 0,
};
// The default color for edges.
const DEFAULT_EDGE_COLOR = '#999';
// A map from GraphEdgeColor to its start and end color codes. The property
// `targetDefId` will be used as the unique ID of the colored arrow in the SVG
// defs (https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs), and the
// arrowhead will be referred to by `url(#targetDefId)` in the SVG.
const EDGE_COLORS = {
[GraphEdgeColor.DEFAULT]: {
source: DEFAULT_EDGE_COLOR,
target: DEFAULT_EDGE_COLOR,
targetDefId: 'graph-arrowhead-default',
},
[GraphEdgeColor.GREY_GRADIENT]: {
source: '#ddd',
target: '#666',
targetDefId: 'graph-arrowhead-grey-gradient',
},
[GraphEdgeColor.BLUE_TO_RED]: {
source: '#00f',
target: '#f00',
targetDefId: 'graph-arrowhead-blue-to-red',
},
};
// Parameters determining the force-directed simulation cooldown speed.
......@@ -70,13 +97,15 @@ function getNodeColor(node) {
/**
* Adds a def for an arrowhead (triangle) marker to the SVG.
* @param {*} defs The d3 selection of the SVG defs.
* @param {string} id The HTML id for the arrowhead.
* @param {string} color The color of the arrowhead.
* @param {number} length The length of the arrowhead.
* @param {number} width The width of the arrowhead.
*/
function addArrowMarkerDef(defs, length, width) {
function addArrowMarkerDef(defs, id, color, length, width) {
const halfWidth = Math.floor(width / 2);
defs.append('marker')
.attr('id', DEF_IDS.GRAPH_ARROWHEAD)
.attr('id', id) // 'graph-arrowhead-*'
.attr('viewBox', `0 -${halfWidth} ${length} ${width}`)
// TODO(yjlong): 5 is the hardcoded radius, change for dynamic radius.
.attr('refX', length + 5)
......@@ -86,7 +115,7 @@ function addArrowMarkerDef(defs, length, width) {
.attr('markerHeight', width)
.append('path')
.attr('d', `M 0 -${halfWidth} L ${length} 0 L 0 ${halfWidth}`)
.attr('fill', '#999')
.attr('fill', color)
.style('stroke', 'none');
}
......@@ -109,6 +138,39 @@ function countNumReheatTicks() {
return reheatTicks;
}
/**
* Wrapper class around the logic for the currently hovered node.
*
* The hovered node should not change when being dragged, even if the "real"
* hovered node (as determined by mouse position) changes during the drag (e.g.,
* if the mouse moves too fast). Update takes place when drag ends, i.e., the
* dragged node become unhovered after drag if the mouse came off the node.
*/
class HoveredNodeManager {
constructor() {
/** @public {?GraphNode} */
this.hoveredNode = null;
/** @private {?GraphNode} */
this.realHoveredNode_ = null;
/** @private {boolean} */
this.isDragging_ = false;
}
setDragging(isDragging) {
this.isDragging_ = isDragging;
if (!this.isDragging_) {
// When the drag ends, update the hovered node.
this.hoveredNode = this.realHoveredNode_;
}
}
setHoveredNode(hoveredNode) {
this.realHoveredNode_ = hoveredNode;
if (!this.isDragging_) {
// The hovered node can only be updated when not dragging.
this.hoveredNode = this.realHoveredNode_;
}
}
}
/**
* A callback to be triggered whenever a node is clicked in the visualization.
* @callback OnNodeClickedCallback
......@@ -123,14 +185,26 @@ class GraphView {
* we can maybe change this to bind to a given DOM element if necessary.
*/
constructor() {
/** @private {number} */
this.edgeCurveOffset_ = EDGE_CURVE_OFFSET.CURVED;
/** @private {boolean} */
this.colorEdgesOnlyOnHover_ = true;
/** @private {string} */
this.graphEdgeColor_ = GraphEdgeColor.DEFAULT;
/** @private {!HoveredNodeManager} */
this.hoveredNodeManager_ = new HoveredNodeManager();
/** @private @type {?OnNodeClickedCallback} */
this.onNodeClicked_ = null;
const svg = d3.select('#graph-svg');
const graphGroup = svg.append('g'); // Contains entire graph (for zoom/pan).
const svgDefs = svg.append('defs');
this.svgDefs_ = svg.append('defs');
addArrowMarkerDef(svgDefs, 10, 4);
// Add an arrowhead def for every possible edge target color.
for (const {target, targetDefId} of Object.values(EDGE_COLORS)) {
addArrowMarkerDef(this.svgDefs_, targetDefId, target, 10, 6);
}
// Set up zoom and pan on the entire graph.
svg.call(d3.zoom()
......@@ -144,7 +218,8 @@ class GraphView {
/** @private {*} */
this.edgeGroup_ = graphGroup.append('g')
.classed('graph-edges', true)
.attr('stroke-width', 1);
.attr('stroke-width', 1)
.attr('fill', 'transparent');
/** @private {*} */
this.nodeGroup_ = graphGroup.append('g')
.classed('graph-nodes', true)
......@@ -231,6 +306,96 @@ class GraphView {
return (this.velocityDecayScale_(normalizedEaseTick));
}
/** Synchronizes the path of all edges to their underlying data. */
syncEdgePaths() {
this.edgeGroup_.selectAll('path')
.attr('d', edge => {
// To calculate the control point, consider the edge vector [dX, dY]:
// * Flip over Y axis (since SVGs increase y downwards): [dX, -dY]
// * Rotate 90 degrees clockwise: [-dY, -dX]
// * Normalize and multiply by curve offset: offset/norm * [-dY, -dX]
// * Flip over Y again to get SVG coords: offset/norm * [-dY, dX]
// * Add to midpoint: [midX, midY] + offset/norm * [-dY, dX]
const deltaX = edge.target.x - edge.source.x;
const deltaY = edge.target.y - edge.source.y;
if (deltaX === 0 && deltaY === 0) {
return null; // Do not draw paths for self-edges.
}
const midX = (edge.source.x + edge.target.x) / 2;
const midY = (edge.source.y + edge.target.y) / 2;
const scaleFactor =
this.edgeCurveOffset_ / Math.hypot(deltaX, deltaY);
const controlPointX = midX - scaleFactor * deltaY;
const controlPointY = midY + scaleFactor * deltaX;
const path = d3.path();
path.moveTo(edge.source.x, edge.source.y);
path.quadraticCurveTo(
controlPointX, controlPointY, edge.target.x, edge.target.y);
return path.toString();
});
}
/** Synchronizes the color of all edges to their underlying data. */
syncEdgeColors() {
const hoveredNode = this.hoveredNodeManager_.hoveredNode;
const edgeTouchesHoveredNode = edge =>
edge.source === hoveredNode || edge.target === hoveredNode;
const nodeTouchesHoveredNode = node => {
return node === hoveredNode ||
hoveredNode.inbound.has(node) || hoveredNode.outbound.has(node);
};
// Point the associated gradient in the direction of the line.
this.svgDefs_.selectAll('linearGradient')
.attr('x1', edge => edge.source.x)
.attr('y1', edge => edge.source.y)
.attr('x2', edge => edge.target.x)
.attr('y2', edge => edge.target.y);
this.edgeGroup_.selectAll('path')
.attr('marker-end', edge => {
if (edge.source === edge.target) {
return null;
} else if (!this.colorEdgesOnlyOnHover_ ||
edgeTouchesHoveredNode(edge)) {
return `url(#${EDGE_COLORS[this.graphEdgeColor_].targetDefId})`;
}
return `url(#${EDGE_COLORS[GraphEdgeColor.DEFAULT].targetDefId})`;
})
.attr('stroke', edge => {
if (!this.colorEdgesOnlyOnHover_ || edgeTouchesHoveredNode(edge)) {
return `url(#${edge.id})`;
}
return DEFAULT_EDGE_COLOR;
})
.classed('non-hovered-edge', edge => {
return this.colorEdgesOnlyOnHover_ &&
hoveredNode !== null && !edgeTouchesHoveredNode(edge);
});
this.labelGroup_.selectAll('text')
.classed('non-hovered-text', node => {
return this.colorEdgesOnlyOnHover_ &&
hoveredNode !== null && !nodeTouchesHoveredNode(node);
});
}
/** Updates the colors of the edge gradients to match the selected color. */
syncEdgeGradients() {
const {source, target} = EDGE_COLORS[this.graphEdgeColor_];
const gradientSelection = this.svgDefs_.selectAll('linearGradient');
gradientSelection.selectAll('stop').remove();
gradientSelection.append('stop')
.attr('offset', '0%')
.attr('stop-color', source);
gradientSelection.append('stop')
.attr('offset', '100%')
.attr('stop-color', target);
}
/**
* Reheats the simulation, allowing all nodes to move according to the physics
* simulation until they cool down again.
......@@ -244,11 +409,8 @@ class GraphView {
// The simulation updates position variables in the data every tick, it's up
// to us to update the visualization to match.
const tickActions = () => {
this.edgeGroup_.selectAll('line')
.attr('x1', edge => edge.source.x)
.attr('y1', edge => edge.source.y)
.attr('x2', edge => edge.target.x)
.attr('y2', edge => edge.target.y);
this.syncEdgePaths();
this.syncEdgeColors();
this.nodeGroup_.selectAll('circle')
.attr('cx', node => node.x)
......@@ -276,6 +438,26 @@ class GraphView {
.restart();
}
/**
* Updates the display settings for the visualization.
* @param {!DisplaySettingsData} displaySettings The display config.
*/
updateDisplaySettings(displaySettings) {
const {
curveEdges,
colorOnlyOnHover,
graphEdgeColor,
} = displaySettings;
this.edgeCurveOffset_ = curveEdges ?
EDGE_CURVE_OFFSET.CURVED : EDGE_CURVE_OFFSET.STRAIGHT;
this.colorEdgesOnlyOnHover_ = colorOnlyOnHover;
this.graphEdgeColor_ = graphEdgeColor;
this.syncEdgeGradients();
this.syncEdgePaths();
this.syncEdgeColors();
}
/**
* Updates the data source used for the visualization.
*
......@@ -284,23 +466,18 @@ class GraphView {
updateGraphData(inputData) {
const {nodes: inputNodes, edges: inputEdges} = inputData;
this.simulation_
.nodes(inputNodes)
.force('links', d3.forceLink(inputEdges).id(edge => edge.id));
this.simulation_.stop();
let nodesAddedOrRemoved = false;
this.svgDefs_.selectAll('linearGradient')
.data(inputEdges, edge => edge.id)
.join(enter => enter.append('linearGradient')
.attr('id', edge => edge.id)
.attr('gradientUnits', 'userSpaceOnUse'));
// TODO(yjlong): Determine if we ever want to render self-loops (will need
// to be a loop instead of a straight line) and handle accordingly.
this.edgeGroup_.selectAll('line')
this.edgeGroup_.selectAll('path')
.data(inputEdges, edge => edge.id)
.join(enter => enter.append('line')
.attr('marker-end', edge => {
if ( edge.source === edge.target ) {
return null;
}
return `url(#${DEF_IDS.GRAPH_ARROWHEAD})`;
}));
.join(enter => enter.append('path'));
this.nodeGroup_.selectAll('circle')
.data(inputNodes, node => node.id)
......@@ -311,14 +488,24 @@ class GraphView {
return enter.append('circle')
.attr('r', 5)
.on('mousedown', node => this.onNodeClicked_(node))
.on('mouseenter', node => {
this.hoveredNodeManager_.setHoveredNode(node);
this.syncEdgeColors();
})
.on('mouseleave', () => {
this.hoveredNodeManager_.setHoveredNode(null);
this.syncEdgeColors();
})
.call(d3.drag()
.on('start', () => this.hoveredNodeManager_.setDragging(true))
.on('drag', (node, idx, nodes) => {
this.reheatSimulation(/* shouldEase */ false);
d3.select(nodes[idx]).classed('locked', true);
// Fix the node's position after it has been dragged.
node.fx = d3.event.x;
node.fy = d3.event.y;
}))
})
.on('end', () => this.hoveredNodeManager_.setDragging(false)))
.on('click', (node, idx, nodes) => {
if (d3.event.defaultPrevented) {
return; // Skip drag events.
......@@ -362,7 +549,11 @@ class GraphView {
// The graph should not be reheated on a no-op (eg. adding a visible node to
// the filter which doesn't add/remove any new nodes).
this.simulation_
.nodes(inputNodes)
.force('links', d3.forceLink(inputEdges).id(edge => edge.id));
if (nodesAddedOrRemoved) {
this.simulation_.stop();
this.reheatSimulation(/* shouldEase */ true);
}
}
......
......@@ -4,6 +4,26 @@
import {GraphModel, D3GraphData} from './graph_model.js';
/**
* Various different graph edge color schemes.
* @enum {string}
*/
const GraphEdgeColor = {
DEFAULT: 'default',
GREY_GRADIENT: 'grey-gradient',
BLUE_TO_RED: 'blue-to-red',
};
/**
* The data configuring the visualization's display.
* @typedef {Object} DisplaySettingsData
* @property {boolean} curveEdges Whether edges should be curved.
* @property {boolean} colorOnlyOnHover Whether edge colors should only be shown
* when hovering on nodes touching those edges.
* @property {GraphEdgeColor} graphEdgeColor The color of the edges.
*/
let DisplaySettingsData;
/**
* A container representing the visualization's node filter. Nodes included in
* the filter are allowed to be displayed on the graph.
......@@ -93,6 +113,13 @@ class PageModel {
this.outboundDepthData = {
outboundDepth: 0,
};
/** @public {!DisplaySettingsData} */
this.displaySettingsData = {
curveEdges: true,
colorOnlyOnHover: true,
graphEdgeColor: GraphEdgeColor.DEFAULT,
};
}
/**
......@@ -116,6 +143,8 @@ class PageModel {
}
export {
DisplaySettingsData,
GraphEdgeColor,
NodeFilterData,
PageModel,
};
......@@ -22,6 +22,8 @@
:page-model="pageModel"
@[CUSTOM_EVENTS.NODE_CLICKED]="graphNodeClicked"/>
<div id="node-details-container">
<GraphDisplaySettings
:display-settings-data="pageModel.displaySettingsData"/>
<GraphSelectedNodeDetails
:selected-node-details-data="pageModel.selectedNodeDetailsData"
@[CUSTOM_EVENTS.ADD_TO_FILTER_CLICKED]="addNodeToFilter"
......@@ -45,6 +47,7 @@ import {PageModel} from '../page_model.js';
import {parseClassGraphModelFromJson} from '../process_graph_json.js';
import ClassDetailsPanel from './class_details_panel.vue';
import GraphDisplaySettings from './graph_display_settings.vue';
import GraphFilterInput from './graph_filter_input.vue';
import GraphFilterItems from './graph_filter_items.vue';
import GraphInboundInput from './graph_inbound_input.vue';
......@@ -57,6 +60,7 @@ import PageUrlGenerator from './page_url_generator.vue';
const ClassGraphPage = {
components: {
ClassDetailsPanel,
GraphDisplaySettings,
GraphFilterInput,
GraphFilterItems,
GraphInboundInput,
......@@ -92,6 +96,7 @@ const ClassGraphPage = {
CUSTOM_EVENTS: () => CUSTOM_EVENTS,
graphUpdateTriggers: function() {
return [
this.pageModel.displaySettingsData,
this.pageModel.nodeFilterData.nodeList,
this.pageModel.inboundDepthData.inboundDepth,
this.pageModel.outboundDepthData.outboundDepth,
......
<!-- Copyright 2020 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file. -->
<template>
<div id="display-settings">
<div>
<input
id="curve-edges"
v-model="displaySettingsData.curveEdges"
type="checkbox">
<label for="curve-edges">Curve graph edges</label>
</div>
<div>
<input
id="color-on-hover"
v-model="displaySettingsData.colorOnlyOnHover"
type="checkbox">
<label for="color-on-hover">Color graph edges only on node hover</label>
</div>
<label for="graph-edge-color">Graph edge color scheme:</label>
<select
id="graph-edge-color"
v-model="displaySettingsData.graphEdgeColor">
<option
v-for="edgeColor in GraphEdgeColor"
:key="edgeColor"
:value="edgeColor">
{{ edgeColor }}
</option>
</select>
</div>
</template>
<script>
import {GraphEdgeColor} from '../page_model.js';
// @vue/component
const GraphDisplaySettings = {
props: {
displaySettingsData: Object,
},
computed: {
GraphEdgeColor: () => GraphEdgeColor,
},
};
export default GraphDisplaySettings;
</script>
<style scoped>
#display-settings {
display: flex;
flex-direction: column;
margin-bottom: 10px;
}
</style>
......@@ -36,6 +36,8 @@ const GraphVisualization = {
graphUpdateTriggers: {
handler: function() {
this.graphView.updateGraphData(this.pageModel.getDataForD3());
this.graphView.updateDisplaySettings(
this.pageModel.displaySettingsData);
},
deep: true,
},
......@@ -56,9 +58,8 @@ export default GraphVisualization;
</script>
<style>
.graph-edges line {
stroke: #999;
stroke-opacity: 0.6;
.graph-edges path.non-hovered-edge {
opacity: 0.4;
}
.graph-nodes circle {
......@@ -71,6 +72,10 @@ export default GraphVisualization;
font-size: 12px;
}
.graph-labels text.non-hovered-text {
opacity: 0.4;
}
.graph-nodes circle.locked {
stroke: #000;
stroke-width: 3;
......
......@@ -56,7 +56,7 @@ export default PackageDetailsPanel;
<style scoped>
.package-details-panel {
max-height: 400px;
max-height: 300px;
overflow: hidden;
overflow-y: scroll;
}
......
......@@ -22,6 +22,8 @@
:page-model="pageModel"
@[CUSTOM_EVENTS.NODE_CLICKED]="graphNodeClicked"/>
<div id="node-details-container">
<GraphDisplaySettings
:display-settings-data="pageModel.displaySettingsData"/>
<GraphSelectedNodeDetails
:selected-node-details-data="pageModel.selectedNodeDetailsData"
@[CUSTOM_EVENTS.ADD_TO_FILTER_CLICKED]="addNodeToFilter"
......@@ -44,6 +46,7 @@ import {GraphNode} from '../graph_model.js';
import {PageModel} from '../page_model.js';
import {parsePackageGraphModelFromJson} from '../process_graph_json.js';
import GraphDisplaySettings from './graph_display_settings.vue';
import GraphFilterInput from './graph_filter_input.vue';
import GraphFilterItems from './graph_filter_items.vue';
import GraphInboundInput from './graph_inbound_input.vue';
......@@ -56,6 +59,7 @@ import PageUrlGenerator from './page_url_generator.vue';
// @vue/component
const PackageGraphPage = {
components: {
GraphDisplaySettings,
GraphFilterInput,
GraphFilterItems,
GraphInboundInput,
......@@ -92,6 +96,7 @@ const PackageGraphPage = {
CUSTOM_EVENTS: () => CUSTOM_EVENTS,
graphUpdateTriggers: function() {
return [
this.pageModel.displaySettingsData,
this.pageModel.nodeFilterData.nodeList,
this.pageModel.inboundDepthData.inboundDepth,
this.pageModel.outboundDepthData.outboundDepth,
......
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