| // 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. |
| |
| /** |
| * @fileoverview 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); |
| } |
| } |
| |
| /** |
| * 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', () => |
| graphGroup.attr('transform', d3.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. |
| * @returns {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; |
| } |
| 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)) |
| .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. |
| } |
| 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; |
| } |
| 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, |
| }; |