blob: e6a36f59eab3c6f4769f06c8edd464321fcb0e96 [file] [log] [blame]
// 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.
import {GraphNode, D3GraphData} from './graph_model.js';
// 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',
};
// 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_ON_REHEAT: 1,
ALPHA_MIN: 0.03,
VELOCITY_DECAY_DEFAULT: 0.4,
VELOCITY_DECAY_MAX: 0.99,
};
// 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);
}
}
/**
* Adds a def for an arrowhead (triangle) marker to the SVG.
* @param {*} defs The d3 selection of the SVG defs.
* @param {number} length The length of the arrowhead.
* @param {number} width The width of the arrowhead.
*/
function addArrowMarkerDef(defs, length, width) {
const halfWidth = Math.floor(width / 2);
defs.append('marker')
.attr('id', DEF_IDS.GRAPH_ARROWHEAD)
.attr('viewBox', `0 -${halfWidth} ${length} ${width}`)
// TODO(yjlong): 5 is the hardcoded radius, change for dynamic radius.
.attr('refX', length + 5)
.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', '#999')
.style('stroke', 'none');
}
/**
* 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;
}
/**
* A callback to be triggered whenever a node is clicked in the visualization.
* @callback OnNodeClickedCallback
* @param {!GraphNode} node The node that was clicked.
*/
/** 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 @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');
addArrowMarkerDef(svgDefs, 10, 4);
// Set up zoom and pan on the entire graph.
svg.call(d3.zoom()
.scaleExtent([0.25, 10])
.on('zoom', () =>
graphGroup.attr('transform', d3.event.transform),
));
// The order of these groups decide the SVG paint order (since we append
// sequentially), we want edges below nodes below labels.
/** @private {*} */
this.edgeGroup_ = graphGroup.append('g')
.classed('graph-edges', true)
.attr('stroke-width', 1);
/** @private {*} */
this.nodeGroup_ = graphGroup.append('g')
.classed('graph-nodes', true)
.attr('fill', 'red');
/** @private {*} */
this.labelGroup_ = graphGroup.append('g')
.classed('graph-labels', true)
.attr('pointer-events', 'none');
// TODO(yjlong): SVG should be resizable & these values updated.
const width = +svg.attr('width');
const height = +svg.attr('height');
const centeringStrengthY = 1.0;
const centeringStrengthX = centeringStrengthY * (height / width);
/** @private {*} */
this.simulation_ = d3.forceSimulation()
.alphaMin(SIMULATION_SPEED_PARAMS.ALPHA_MIN)
.force('chargeForce', d3.forceManyBody().strength(-3000))
.force('centerXForce',
d3.forceX(width / 2).strength(centeringStrengthX))
.force('centerYForce',
d3.forceY(height / 2).strength(centeringStrengthY));
/** @private {number} */
this.reheatTicks_ = countNumReheatTicks();
/**
* @callback LinearScaler
* @param {number} input The input value between [0, 1] inclusive.
* @returns {number} The input scaled linearly to new bounds.
*/
/** @private {LinearScaler} */
this.velocityDecayScale_ = d3.scaleLinear()
.domain([0, 1])
.range([SIMULATION_SPEED_PARAMS.VELOCITY_DECAY_DEFAULT,
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;
}
/**
* 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));
}
/**
* 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;
// 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.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);
tickNum ++;
if (shouldEase) {
this.simulation_.velocityDecay(this.getEasedVelocityDecay(tickNum));
}
};
// 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 data source used for the visualization.
*
* @param {!D3GraphData} inputData The new data to use.
*/
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;
// 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')
.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})`;
}));
this.nodeGroup_.selectAll('circle')
.data(inputNodes, node => node.id)
.join(enter => {
if (!enter.empty()) {
nodesAddedOrRemoved = true;
}
return enter.append('circle')
.attr('r', 5)
.on('mousedown', node => this.onNodeClicked_(node))
.call(d3.drag()
.on('drag', (node, idx, nodes) => {
// TODO(yjlong): This should ideally be a call with `false`
// (dragging should continue the simulation with constant
// velocity), but for some reason the graph goes crazy with
// that setting. Investigate and fix.
this.reheatSimulation(/* shouldEase */ true);
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('click', (node, idx, nodes) => {
if (d3.event.defaultPrevented) {
return; // Skip drag events.
}
const pageNode = d3.select(nodes[idx]);
if (pageNode.classed('locked')) {
node.fx = null;
node.fy = null;
this.reheatSimulation(/* shouldEase */ true);
} else {
node.fx = node.x;
node.fy = node.y;
}
// TODO(yjlong): Change this so the style is tied to whether the
// fx/fy are non-null instead of toggling it each time.
pageNode.classed('locked', !pageNode.classed('locked'));
});
},
update => update,
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 => getNodeColor(node));
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 (nodesAddedOrRemoved) {
this.reheatSimulation(/* shouldEase */ true);
}
}
}
export {
GraphView,
};