// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @file Exports a class GraphView, which encapsulates all the D3 logic
* in the graph visualization.
*
* D3 uses the concept of "data joins" to bind data to the SVG, and might be
* difficult to read at first glance. This (https://bost.ocks.org/mike/join/) is
* a good resource if you are unfamiliar with data joins.
*/
import {DisplaySettingsData} from './display_settings_data.js';
import {GraphNode, D3GraphData} from './graph_model.js';
import {GraphEdgeColor} from './display_settings_data.js';
import * as d3 from 'd3';
// The radius of each node in the visualization.
const NODE_RADIUS = 5;
// Category10 colors pulled from https://observablehq.com/@d3/color-schemes.
const HULL_COLORS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'];
// The distance the hull should be expanded from the original nodes.
const HULL_EXPANSION = 20;
// 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, 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.
// For more info on each param, see https://github.com/d3/d3-force.
const SIMULATION_SPEED_PARAMS = {
// Alpha (or temperature, as in simulated annealing) is reset to this value
// for the simulation is reheated.
ALPHA_ON_REHEAT: 0.3,
// Alpha at which simulation freezes.
ALPHA_MIN: 0.001,
// A higher velocity decay indicates slower nodes (node movement is multiplied
// by (1-decay) each tick).
// The decay to use when there is no easing (eg. dragging nodes).
VELOCITY_DECAY_DEFAULT: 0.8,
// When nodes are eased, their decay starts at MAX (slow),
// transitions to MIN (fast), then back to MAX.
VELOCITY_DECAY_MAX: 0.99,
VELOCITY_DECAY_MIN: 0,
};
// Colors for displayed nodes. These colors are temporary.
const NODE_COLORS = {
FILTER: d3.color('red'),
INBOUND: d3.color('#000080'), // Dark blue.
OUTBOUND: d3.color('#666600'), // Dark yellow.
INBOUND_AND_OUTBOUND: d3.color('#006400'), // Dark green.
};
/**
* Computes the color to display for a given node.
*
* @param {!GraphNode} node The node in question.
* @return {string} The color of the node.
*/
function getNodeColor(node) {
if (node.visualizationState.selectedByFilter) {
return NODE_COLORS.FILTER;
}
if (node.visualizationState.selectedByInbound &&
node.visualizationState.selectedByOutbound) {
return NODE_COLORS.INBOUND_AND_OUTBOUND.brighter(
Math.min(node.visualizationState.inboundDepth,
node.visualizationState.outboundDepth));
}
if (node.visualizationState.selectedByInbound) {
return NODE_COLORS.INBOUND.brighter(
node.visualizationState.inboundDepth);
}
if (node.visualizationState.selectedByOutbound) {
return NODE_COLORS.OUTBOUND.brighter(
node.visualizationState.outboundDepth);
}
throw new Error(`Unexpected node visualization state ${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, id, color, length, width) {
const halfWidth = Math.floor(width / 2);
defs.append('marker')
.attr('id', id) // 'graph-arrowhead-*'
.attr('viewBox', `0 -${halfWidth} ${length} ${width}`)
.attr('refX', length + NODE_RADIUS)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', length)
.attr('markerHeight', width)
.append('path')
.attr('d', `M 0 -${halfWidth} L ${length} 0 L 0 ${halfWidth}`)
.attr('fill', color)
.style('stroke', 'none');
}
/**
* Creates a polygon (array of [x, y] pairs) that can be converted into a
* valid convex hull. For a polygon to be valid for conversion, it must have
* at least 3 points.
*
* @param {!Array<!GraphNode>} nodes The nodes to generate a polygon for. This
* array must have at least one element.
* @return {!Array<!Array<number>>} A valid polygon for the input nodes.
*/
function getValidHullPolygon(nodes) {
let nodePolygon = nodes.map(node => [node.x, node.y]);
if (nodePolygon.length === 1) {
// If there is only one point, replace it with four points in a square
// diamond surrounding the original point.
const [x0, y0] = nodePolygon[0];
nodePolygon = [[x0 + 1, y0], [x0 - 1, y0], [x0, y0 + 1], [x0, y0 - 1]];
} else if (nodePolygon.length === 2) {
// If there are two points, add another two slightly offset points
// parallel to the line created by the original two points.
const [x0, y0] = nodePolygon[0];
const [x1, y1] = nodePolygon[1];
const deltaX = x1 - x0;
const deltaY = y1 - y0;
const [offsetX, offsetY] = resizeVector(deltaX, deltaY, 1);
/* eslint-disable indent */
// Here, we rotate the offset vector 90deg by negating x and swapping x, y.
nodePolygon.push([x0 + offsetY, y0 - offsetX],
[x1 + offsetY, y1 - offsetX]);
}
// If there are >= 3 points, it is already valid for convex hull conversion.
return nodePolygon;
}
/**
* When we reheat the simulation, we'd like to know how many ticks it will take
* to cool. Instead of finding an formula for our specific config, we just make
* a throwaway simulation with our config and run it to completion.
*
* @return {number} The number of ticks it will take for a reheat to cool.
*/
function countNumReheatTicks() {
let reheatTicks = 0;
const reheatTicksCounter = d3.forceSimulation()
.alphaMin(SIMULATION_SPEED_PARAMS.ALPHA_MIN)
.alpha(SIMULATION_SPEED_PARAMS.ALPHA_ON_REHEAT);
while (reheatTicksCounter.alpha() > reheatTicksCounter.alphaMin()) {
reheatTicks++;
reheatTicksCounter.tick();
}
return reheatTicks;
}
/**
* Helper to scale a vector to a given magnitude. If [x, y] is nearly the zero
* vector, then [newMagnitude, 0] is returned.
*
* @param {number} x The x-coordinate of the vector.
* @param {number} y The y-coordinate of the vector.
* @param {number} newMagnitude The magnitude to scale the vector to.
* @return {!Array<number>} The scaled vector in the form [x, y].
*/
function resizeVector(x, y, newMagnitude) {
const normalFactor = Math.hypot(x, y);
if (normalFactor < 1e-12) {
return [newMagnitude, 0];
}
const scaleFactor = newMagnitude / normalFactor;
return [x * scaleFactor, y * scaleFactor];
}
/**
* 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_;
}
}
}
/**
* Wrapper class around the logic to assign colors to convex hulls.
*
* Each hull should have a unique key that remains associated with the hull
* throughout its lifetime.
*/
class HullColorManager {
constructor() {
this.colorIndex_ = 0;
this.hullColors_ = new Map();
}
/**
* Gets the next color from the array of colors `HULL_COLORS`. When all the
* colors of the array are used, starts from the beginning again.
*
* @return {string} The next color to use.
*/
getNextColor_() {
this.colorIndex_ = (this.colorIndex_ + 1) % HULL_COLORS.length;
return HULL_COLORS[this.colorIndex_];
}
/**
* Gets the color associated with a given hull, generating a color for it if
* there isn't one already.
*
* @param {string} hullKey A key uniquely identifying the hull.
* @return {string} The color associated with the hull's key.
*/
getColorForHull(hullKey) {
if (!this.hullColors_.has(hullKey)) {
this.hullColors_.set(hullKey, this.getNextColor_());
}
return this.hullColors_.get(hullKey);
}
}
/**
* @typedef {object} PhantomTextNode A node that isn't displayed in the
* visualization, but affects the simulation. Used to prevent text overlap
* by being fixed to the right of real nodes (where the text is drawn).
* @property {boolean} isPhantomTextNode A flag (always true) used to
* differentiate these nodes from real ones when processing the simulation.
* @property {!GraphNode} refNode A reference to the node that this phantom node
* is attached to.
* @property {number} dist The distance away from `refNode` that this node
* should be fixed.
*/
/**
* A callback to be triggered whenever a node is clicked in the visualization.
*
* @callback OnNodeClickedCallback
* @param {!GraphNode} node The node that was clicked.
*/
/**
* A callback to be triggered whenever a node is double-clicked in the
* visualization.
*
* @callback OnNodeDoubleClickedCallback
* @param {!GraphNode} node The node that was double-clicked.
*/
/**
* Returns the group a node is in, or `null` if the node shouldn't be grouped.
*
* @callback GetNodeGroupCallback
* @param {!GraphNode} node The node to find the group for.
* @return {?string} The unique key identifying the node's group, or `null` if
* the node shouldn't be grouped.
*/
/** The view of the visualization, controlling display on the SVG. */
class GraphView {
/**
* Initializes some variables and performs one-time setup of the SVG canvas.
* Currently just binds to the only 'svg' object, as things get more complex
* 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 {boolean} */
this.reheatRequested_ = false;
/** @private {?string} */
this.lastHullDisplay_ = null;
/** @private {!HoveredNodeManager} */
this.hoveredNodeManager_ = new HoveredNodeManager();
/** @private {!HullColorManager} */
this.hullColorManager_ = new HullColorManager();
// Event handler callbacks, to be registered externally and called when the
// relevant event is triggered.
/** @private @type {?GetNodeGroupCallback} */
this.getNodeGroup_ = null;
/** @private @type {?OnNodeClickedCallback} */
this.onNodeClicked_ = null;
/** @private @type {?OnNodeDoubleClickedCallback} */
this.onNodeDoubleClicked_ = null;
const svg = d3.select('#graph-svg');
// An SVG group containing the entire graph as its children (for zoom/pan).
const graphGroup = svg.append('g');
// The defs element for the entire SVG
// (https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs).
this.svgDefs_ = svg.append('defs');
// 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()
.scaleExtent([0.25, 10])
.on('zoom', event =>
graphGroup.attr('transform', event.transform),
))
.on('dblclick.zoom', null);
// Each group here will have a collection of SVG elements as their children.
// The order of these groups decide the SVG paint order (since we append
// sequentially), earlier groups will be painted below later ones.
/**
* @private {*} The convex hulls displayed around node groupings. Contains
* <path/> elements as children, each hull is one <path/>.
*/
this.hullGroup_ = graphGroup.append('g')
.classed('graph-hull', true)
.attr('stroke-width', 1)
.attr('fill-opacity', 0.1);
/**
* @private {*} The edges of the graph. Contains <path/> elements as
* children, each edge is one <path/>.
*/
this.edgeGroup_ = graphGroup.append('g')
.classed('graph-edges', true)
.attr('stroke-width', 1)
.attr('fill', 'transparent');
/**
* @private {*} The nodes of the graph. Contains <circle/> elements as
* children, each node is one <circle/>.
*/
this.nodeGroup_ = graphGroup.append('g')
.classed('graph-nodes', true);
/**
* @private {*} The labels for the convex hulls contained in
* `this.hullGroup_`. Contains <text/> elements as children, each hull label
* is one <text/>.
*/
this.hullLabelGroup_ = graphGroup.append('g')
.classed('graph-hull-labels', true)
.attr('pointer-events', 'none');
/**
* @private {*} The labels for the nodes in `this.nodeGroup_`. Contains
* <text/> elements as children, each node label is one <text/>.
*/
this.labelGroup_ = graphGroup.append('g')
.classed('graph-labels', true)
.attr('pointer-events', 'none');
/** @private {!Array<!PhantomTextNode>} */
this.phantomTextNodes_ = [];
// Using .style() instead of .attr() gets px-based measurements of
// percentage-based widths and heights.
const width = parseInt(svg.style('width'), 10);
const height = parseInt(svg.style('height'), 10);
// Initializes forces in the force-directed simulation, which will update
// position variables on the input data as the simulation runs.
const centeringStrengthY = 0.07;
const centeringStrengthX = centeringStrengthY * (height / width);
/** @private {*} */
this.simulation_ = d3.forceSimulation()
.alphaMin(SIMULATION_SPEED_PARAMS.ALPHA_MIN)
.force('chargeForce', d3.forceManyBody().strength(node => {
if (node.isPhantomTextNode) {
return -1100;
}
return -3000;
}))
.force('centerXForce',
d3.forceX(width / 2).strength(node => {
if (node.isPhantomTextNode) {
return 0;
}
if (node.visualizationState.selectedByFilter) {
return centeringStrengthX * 15;
}
if (node.visualizationState.outboundDepth <= 1) {
return centeringStrengthX * 5;
}
return centeringStrengthY;
}))
.force('centerYForce',
d3.forceY(height / 2).strength(node => {
if (node.isPhantomTextNode) {
return 0;
}
if (node.visualizationState.selectedByFilter) {
return centeringStrengthY * 15;
}
if (node.visualizationState.outboundDepth <= 1) {
return centeringStrengthY * 5;
}
return centeringStrengthY;
}));
/** @private {number} */
this.reheatTicks_ = countNumReheatTicks();
/**
* @callback LinearScaler
* @param {number} input The input value between [0, 1] inclusive.
* @return {number} The input scaled linearly to new bounds.
*/
/** @private {LinearScaler} */
this.velocityDecayScale_ = d3.scaleLinear()
.domain([0, 1])
.range([SIMULATION_SPEED_PARAMS.VELOCITY_DECAY_MIN,
SIMULATION_SPEED_PARAMS.VELOCITY_DECAY_MAX]);
}
/**
* Binds the event when a node is clicked in the graph to a given callback.
*
* @param {!OnNodeClickedCallback} onNodeClicked The callback to bind to.
*/
registerOnNodeClicked(onNodeClicked) {
this.onNodeClicked_ = onNodeClicked;
}
/**
* Binds the event when a node is double-clicked in the graph to a given
* callback.
*
* @param {!OnNodeDoubleClickedCallback} onNodeDoubleClicked The callback to
* bind to.
*/
registerOnNodeDoubleClicked(onNodeDoubleClicked) {
this.onNodeDoubleClicked_ = onNodeDoubleClicked;
}
/**
* Assigns the node group accessor to a given function.
*
* @param {!GetNodeGroupCallback} getNodeGroup The function to assign to.
*/
registerGetNodeGroup(getNodeGroup) {
this.getNodeGroup_ = getNodeGroup;
}
/**
* Computes the velocityDecay for our current progress in the reheat process.
*
* See https://github.com/d3/d3-force#simulation_velocityDecay. We animate new
* nodes on the page by starting off with a high decay (slower nodes), easing
* to a low decay (faster nodes), then easing back to a high decay at the end.
* This makes the node animation seem more smooth and natural.
*
* @param {number} currentTick The number of ticks passed in the reheat.
* @return {number} The velocityDecay for the current point in the reheat.
*/
getEasedVelocityDecay(currentTick) {
// The input to the ease function has to be in [0, 1] inclusive. Since
// velocity is multiplied by (1 - decay) and we want speeds of
// slow-fast-slow, the midpoint of the process should correspond to 0 and
// the beginning and end to 1 (ie. the decay should look like \/).
const normalizedCurrentTick = Math.abs(
1 - 2 * (currentTick / this.reheatTicks_));
const normalizedEaseTick = d3.easeQuadInOut(normalizedCurrentTick);
// Since the ease returns a value in [0, 1] inclusive, we have to scale it
// back to our desired velocityDecay range before returning.
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]
// * Flip over Y again to get SVG coords: [-dY, dX]
// * Scale and add to midpoint: [midX, midY] + scaleFactor * [-dY, dX]
// where `scaleFactor` rescales [-dY, dX] to have the length of
// `this.edgeCurveOffset_`.
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 [offsetX, offsetY] = resizeVector(
deltaX, deltaY, this.edgeCurveOffset_);
const controlPointX = midX - offsetY;
const controlPointY = midY + offsetX;
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);
}
/**
* Groups nodes together by using the current group accessor function.
*
* @param {!Array<!GraphNode>} nodes The nodes to group.
* @return {!Map<string, !Array<!GraphNode>>} The map from group key to the
* list of nodes included in that group.
*/
getNodeGroups(nodes) {
const groups = new Map();
for (const node of nodes) {
const groupKey = this.getNodeGroup_(node);
// If the key is null, the node should not be grouped.
if (groupKey !== null) {
if (!groups.has(groupKey)) {
groups.set(groupKey, [node]);
} else {
groups.get(groupKey).push(node);
}
}
}
return groups;
}
/**
* Data representing a convex hull surrounding a certain group.
*
* @typedef {object} HullData
* @property {string} key The unique key for the hull.
* @property {string} color The color to display the hull as.
* @property {!Array<number>} labelPosition An [x, y] point representing where
* this hull's label should be rendered.
* @property {!Array<!Array<number>>} points A list of [x, y] points making up
* the hull.
*/
/**
* Given the node grouping from `getNodeGroups`, constructs a list of convex
* hulls, one per node group.
*
* @param {!Map<string, !Array<!GraphNode>>} nodeGroups The node groupings.
* @return {!Array<!HullData>} A list of convex hulls to display.
*/
getConvexHullData(nodeGroups) {
const resultHulls = [];
for (const [key, nodes] of nodeGroups.entries()) {
const nodePolygon = getValidHullPolygon(nodes);
const baseHull = d3.polygonHull(nodePolygon);
// To expand the hull, we move each point a set distance away from the
// hull's centroid (https://en.wikipedia.org/wiki/Centroid).
const [centroidX, centroidY] = d3.polygonCentroid(baseHull);
const expandedPolygon = baseHull.map(([nodeX, nodeY]) => {
const deltaX = nodeX - centroidX;
const deltaY = nodeY - centroidY;
const [expandX, expandY] = resizeVector(deltaX, deltaY, HULL_EXPANSION);
return [nodeX + expandX, nodeY + expandY];
});
// A decent heuristic is for the hull's label to be displayed above its
// highest node. Recall that on an SVG, lower y-values are higher.
let highestPoint = [0, Number.POSITIVE_INFINITY];
for (const point of expandedPolygon) {
if (point[1] < highestPoint[1]) {
highestPoint = point;
}
}
resultHulls.push({
key,
color: this.hullColorManager_.getColorForHull(key),
labelPosition: highestPoint,
points: expandedPolygon,
});
}
return resultHulls;
}
/**
* Synchronizes the color and position of all convex hulls with their
* underlying data.
*
* @param {!Array<!HullData>} hullData A list of convex hulls to display for
* the current data.
*/
updateHullData(hullData) {
this.hullLabelGroup_.selectAll('text')
.data(hullData, hull => hull.key)
.join(enter => enter.append('text')
.text(hull => hull.key)
.attr('fill', hull => hull.color)
.attr('dy', -8)
.attr('x', hull => hull.labelPosition[0])
.attr('y', hull => hull.labelPosition[1]),
update => update
.attr('x', hull => hull.labelPosition[0])
.attr('y', hull => hull.labelPosition[1]));
// The SVG path generator for the hull outlines.
const getHullLine = d3.line().curve(d3.curveCatmullRomClosed.alpha(0.75));
this.hullGroup_.selectAll('path')
.data(hullData, hull => hull.key)
.join(enter => enter.append('path')
.attr('d', hull => getHullLine(hull.points))
.attr('stroke', hull => hull.color)
.attr('fill', hull => hull.color),
update => update.attr('d', hull => getHullLine(hull.points)));
}
/**
* Reheats the simulation, allowing all nodes to move according to the physics
* simulation until they cool down again.
*
* @param {boolean} shouldEase Whether the node movement should be eased. This
* should not be used when dragging nodes, since the speed at the start of
* the ease will be used all throughout the drag.
*/
reheatSimulation(shouldEase) {
let tickNum = 0;
/**
* Executed every time the simulation updates.
*
* Every tick of the simulation, we need to manually sync the visualization
* with the updated position variables. For more info on the position
* variables, see (https://github.com/d3/d3-force#simulation_nodes).
*
* Any part of the visualization that need to update on tick (e.g., node
* positions) should have their updates be performed in here.
*/
const tickActions = () => {
this.syncEdgePaths();
this.syncEdgeColors();
this.nodeGroup_.selectAll('circle')
.attr('cx', node => node.x)
.attr('cy', node => node.y);
this.labelGroup_.selectAll('text')
.attr('x', label => label.x)
.attr('y', label => label.y);
const hullData = this.getConvexHullData(
this.getNodeGroups(this.nodeGroup_.selectAll('circle').data()));
this.updateHullData(hullData);
tickNum ++;
if (shouldEase) {
this.simulation_.velocityDecay(this.getEasedVelocityDecay(tickNum));
}
// Reset phantom nodes to their fixed positions
this.phantomTextNodes_.forEach(phantomNode => {
phantomNode.x = phantomNode.refNode.x + phantomNode.dist;
phantomNode.y = phantomNode.refNode.y;
});
};
// If we don't ease, the default decay is sufficient for the entire reheat.
const startingVelocityDecay = shouldEase ?
this.getEasedVelocityDecay(0) :
SIMULATION_SPEED_PARAMS.VELOCITY_DECAY_DEFAULT;
this.simulation_
.on('tick', tickActions)
.velocityDecay(startingVelocityDecay)
.alpha(SIMULATION_SPEED_PARAMS.ALPHA_ON_REHEAT)
.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;
// Reheat if node grouping changed.
if (this.lastHullDisplay_ !== displaySettings.hullDisplay) {
this.lastHullDisplay_ = displaySettings.hullDisplay;
this.reheatRequested_ = true;
}
this.syncEdgeGradients();
this.syncEdgePaths();
this.syncEdgeColors();
}
/**
* Generates sparse integers subset in [0, n] that's roughly evenly
* distributed.
*
* @generator
* @param {number} upperBound Exclusive upper bound on generated values.
* @param {number} separation Ideal separation between generated values.
* @yields {number} Generated integers.
*/
*generateSparseInts(upperBound, separation) {
for (let i = separation; i < upperBound; i += separation) {
yield i;
}
// Add an endpoint if the upper bound is far from the last value generated.
if (upperBound % separation >= separation / 2) {
yield upperBound - 1;
}
}
/**
* Updates the data source used for the visualization.
*
* @param {!D3GraphData} inputData The new data to use.
*/
updateGraphData(inputData) {
const DIST_MULTIPLIER = 6.6;
const {nodes: inputNodes, edges: inputEdges} = inputData;
this.phantomTextNodes_ = [];
// Generate the phantom text nodes, the list of nodes to be included in the
// physics simulation at the place where node labels will be rendered.
for (const node of inputNodes) {
// A heuristic in the absence of exact label width: place phantom nodes
// approximately every 10 characters away from the real node.
for (const pos of this.generateSparseInts(node.displayName.length, 10)) {
this.phantomTextNodes_.push({
isPhantomTextNode: true,
refNode: node,
dist: pos * DIST_MULTIPLIER,
});
}
}
this.simulation_
.nodes([...inputNodes, ...this.phantomTextNodes_])
.force('links', d3.forceLink(inputEdges).id(edge => edge.id));
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'));
this.edgeGroup_.selectAll('path')
.data(inputEdges, edge => edge.id)
.join(enter => enter.append('path'));
this.nodeGroup_.selectAll('circle')
.data(inputNodes, node => node.id)
.join(enter => {
if (!enter.empty()) {
nodesAddedOrRemoved = true;
}
const graphView = this;
return enter.append('circle')
.attr('r', 5)
.attr('stroke', node => getNodeColor(node))
.on('dblclick', (_, node) => this.onNodeDoubleClicked_(node))
.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))
// It is necessary to use function instead of => to capture
// the actual circle element in 'this' for d3 v6+.
.on('drag', function(event, node) {
graphView.reheatSimulation(/* shouldEase */ false);
// eslint-disable-next-line no-invalid-this
d3.select(this).classed('locked', true);
// Fix the node's position after it has been dragged.
node.fx = event.x;
node.fy = event.y;
})
.on('end', () => this.hoveredNodeManager_.setDragging(false)))
// It is necessary to use function instead of => to capture
// the actual circle element in 'this' for d3 v6+.
.on('click', function(event, node) {
if (event.defaultPrevented) {
return; // Skip drag events.
}
// eslint-disable-next-line no-invalid-this
const pageNode = d3.select(this);
if (pageNode.classed('locked')) {
node.fx = null;
node.fy = null;
graphView.reheatSimulation(/* shouldEase */ true);
} else {
node.fx = node.x;
node.fy = node.y;
}
pageNode.classed('locked', !pageNode.classed('locked'));
});
},
update => update.attr('stroke', node => getNodeColor(node)),
exit => {
if (!exit.empty()) {
nodesAddedOrRemoved = true;
}
// When a node is removed from the SVG, it should lose all
// position-related data.
return exit.each(node => {
node.x = null;
node.y = null;
node.fx = null;
node.fy = null;
}).remove();
})
.attr('fill', node => {
const nodeGroup = this.getNodeGroup_(node);
if (nodeGroup === null) {
return '#fff';
}
return this.hullColorManager_.getColorForHull(nodeGroup);
});
const nodeGroupings = this.getNodeGroups(inputNodes);
this.updateHullData(this.getConvexHullData(nodeGroupings));
// Create a link force between every pair of nodes in a group, causing the
// nodes to group together in the visualization.
for (const [key, nodes] of nodeGroupings.entries()) {
const links = [];
// If performance is an issue, this can be replaced by picking a
// representative node and connecting all other group members to it. This
// has the downside of inconsistent node interactions: Dragging the
// representative node pulls the entire group with it, while dragging
// non-representative nodes doesn't affect the group as much.
for (let sourceIndex = 0; sourceIndex < nodes.length; sourceIndex++) {
for (let targetIndex = sourceIndex + 1;
targetIndex < nodes.length; targetIndex++) {
links.push({
source: nodes[sourceIndex],
target: nodes[targetIndex],
});
}
}
this.simulation_.force(key, d3.forceLink(links));
}
this.labelGroup_.selectAll('text')
.data(inputNodes, node => node.id)
.join(enter => enter.append('text')
.attr('dx', 12)
.attr('dy', '.35em')
.text(label => label.displayName));
// 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).
if (this.reheatRequested_ || nodesAddedOrRemoved) {
this.reheatRequested_ = false;
this.simulation_.stop();
this.reheatSimulation(/* shouldEase */ true);
}
}
}
export {
GraphView,
};