Fix arrow heads positioning (#2505)

* Fix arrow heads positioning.

* Addressed @foot's comment.

* Beautified the code and added some comments.
This commit is contained in:
Filip Barl
2017-05-12 10:39:22 +02:00
committed by GitHub
parent ec6d5e967a
commit 6d270fe78b
3 changed files with 75 additions and 43 deletions

View File

@@ -5,14 +5,9 @@ import { line, curveBasis } from 'd3-shape';
import { each, omit, times, constant } from 'lodash';
import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation';
import { uniformSelect } from '../utils/array-utils';
import { EDGE_WAYPOINTS_CAP } from '../constants/styles';
import Edge from './edge';
// Tweak this value for the number of control
// points along the edge curve, e.g. values:
// * 2 -> edges are simply straight lines
// * 4 -> minimal value for loops to look ok
const WAYPOINTS_COUNT = 8;
const spline = line()
.curve(curveBasis)
@@ -23,11 +18,11 @@ const transformedEdge = (props, path) => (
<Edge {...props} path={spline(path)} />
);
// Converts a waypoints map of the format {x0: 11, y0: 22, x1: 33, y1: 44}
// Converts a waypoints map of the format { x0: 11, y0: 22, x1: 33, y1: 44 }
// that is used by Motion to an array of waypoints in the format
// [{x: 11, y: 22}, {x: 33, y: 44}] that can be used by D3.
// [{ x: 11, y: 22 }, { x: 33, y: 44 }] that can be used by D3.
const waypointsMapToArray = (waypointsMap) => {
const waypointsArray = times(WAYPOINTS_COUNT, () => ({ x: 0, y: 0}));
const waypointsArray = times(EDGE_WAYPOINTS_CAP, () => ({}));
each(waypointsMap, (value, key) => {
const [axis, index] = [key[0], key.slice(1)];
waypointsArray[index][axis] = value;
@@ -35,6 +30,17 @@ const waypointsMapToArray = (waypointsMap) => {
return waypointsArray;
};
// Converts a waypoints array of the input format [{ x: 11, y: 22 }, { x: 33, y: 44 }]
// to an array of waypoints that is used by Motion in the format { x0: 11, y0: 22, x1: 33, y1: 44 }.
const waypointsArrayToMap = (waypointsArray) => {
let waypointsMap = makeMap();
waypointsArray.forEach((point, index) => {
waypointsMap = waypointsMap.set(`x${index}`, spring(point.x, NODES_SPRING_ANIMATION_CONFIG));
waypointsMap = waypointsMap.set(`y${index}`, spring(point.y, NODES_SPRING_ANIMATION_CONFIG));
});
return waypointsMap;
};
export default class EdgeContainer extends React.PureComponent {
constructor(props, context) {
@@ -65,39 +71,24 @@ export default class EdgeContainer extends React.PureComponent {
return (
// For the Motion interpolation to work, the waypoints need to be in a map format like
// {x0: 11, y0: 22, x1: 33, y1: 44} that we convert to the array format when rendering.
// { x0: 11, y0: 22, x1: 33, y1: 44 } that we convert to the array format when rendering.
<Motion style={this.state.waypointsMap.toJS()}>
{interpolated => transformedEdge(forwardedProps, waypointsMapToArray(interpolated))}
</Motion>
);
}
prepareWaypointsForMotion(nextWaypoints) {
nextWaypoints = nextWaypoints.toJS();
prepareWaypointsForMotion(waypoints) {
waypoints = waypoints.toJS();
// Motion requires a constant number of waypoints along the path of each edge
// for the animation to work correctly, but dagre might be changing their number
// depending on the dynamic topology reconfiguration. Here we are transforming
// the waypoints array given by dagre to the fixed size of `WAYPOINTS_COUNT` that
// Motion could take over.
const waypointsMissing = WAYPOINTS_COUNT - nextWaypoints.length;
// The Motion library requires the number of waypoints to be constant, so we fill in for
// the missing ones by reusing the edge source point, which doesn't affect the edge shape
// because of how the curveBasis interpolation is done.
const waypointsMissing = EDGE_WAYPOINTS_CAP - waypoints.length;
if (waypointsMissing > 0) {
// Whenever there are some waypoints missing, we simply populate the beginning of the
// array with the first element, as this leaves the curve interpolation unchanged.
nextWaypoints = times(waypointsMissing, constant(nextWaypoints[0])).concat(nextWaypoints);
} else if (waypointsMissing < 0) {
// If there are 'too many' waypoints given by dagre, we select a sub-array of
// uniformly distributed indices. Note that it is very important to keep the first
// and the last endpoints in the array as they are the ones connecting the nodes.
nextWaypoints = uniformSelect(nextWaypoints, WAYPOINTS_COUNT);
waypoints = times(waypointsMissing, constant(waypoints[0])).concat(waypoints);
}
let { waypointsMap } = this.state;
nextWaypoints.forEach((point, index) => {
waypointsMap = waypointsMap.set(`x${index}`, spring(point.x, NODES_SPRING_ANIMATION_CONFIG));
waypointsMap = waypointsMap.set(`y${index}`, spring(point.y, NODES_SPRING_ANIMATION_CONFIG));
});
this.setState({ waypointsMap });
this.setState({ waypointsMap: waypointsArrayToMap(waypoints) });
}
}

View File

@@ -2,18 +2,21 @@ import dagre from 'dagre';
import debug from 'debug';
import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
import { NODE_BASE_SIZE } from '../constants/styles';
import { NODE_BASE_SIZE, EDGE_WAYPOINTS_CAP } from '../constants/styles';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { featureIsEnabledAny } from '../utils/feature-utils';
import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils';
import { uniformSelect } from '../utils/array-utils';
const log = debug('scope:nodes-layout');
const topologyCaches = {};
export const DEFAULT_MARGINS = {top: 0, left: 0};
const NODE_SIZE_FACTOR = NODE_BASE_SIZE;
const NODE_SEPARATION_FACTOR = 1.5 * NODE_BASE_SIZE;
const RANK_SEPARATION_FACTOR = 2.5 * NODE_BASE_SIZE;
export const DEFAULT_MARGINS = { top: 0, left: 0 };
// Pretend the nodes are bigger than they are so that the edges would not enter
// them under a high curvature which would cause arrow heads to be misplaced.
const NODE_SIZE_FACTOR = 1.5 * NODE_BASE_SIZE;
const NODE_SEPARATION_FACTOR = 1 * NODE_BASE_SIZE;
const RANK_SEPARATION_FACTOR = 2 * NODE_BASE_SIZE;
let layoutRuns = 0;
let layoutRunsTrivial = 0;
@@ -25,6 +28,42 @@ function fromGraphNodeId(encodedId) {
return encodedId.replace('<DOT>', '.');
}
// Adds some additional waypoints to the edge to make sure the it connects the node
// centers and that the edge enters the target node relatively straight so that the
// arrow is drawn correctly. The total number of waypoints is capped to EDGE_WAYPOINTS_CAP.
function correctedEdgePath(waypoints, source, target) {
// Get the relevant waypoints that will be added/replicated.
const sourcePoint = fromJS({ x: source.get('x'), y: source.get('y') });
const targetPoint = fromJS({ x: target.get('x'), y: target.get('y') });
const entrancePoint = waypoints.last();
if (target !== source) {
// The strategy for the non-loop edges is the following:
// * Uniformly select at most CAP - 4 of the central waypoints ignoring the target node
// entrance point. Such a selection will ensure that both the source node exit point and
// the point before the target node entrance point are taken as boundaries of the interval.
// * Now manually add those 4 points that we always want to have included in the edge path -
// centers of source/target nodes and twice the target node entrance point to ensure the
// edge path actually goes through it and thus doesn't miss the arrow element.
// * In the end, what matters for the arrow is that the last 4 points of the array are always
// fixed regardless of the total number of waypoints. That way we ensure the arrow is drawn
// correctly, but also that the edge path enters the target node smoothly.
waypoints = fromJS(uniformSelect(waypoints.butLast().toJS(), EDGE_WAYPOINTS_CAP - 4));
waypoints = waypoints.unshift(sourcePoint);
waypoints = waypoints.push(entrancePoint);
waypoints = waypoints.push(entrancePoint);
waypoints = waypoints.push(targetPoint);
} else {
// For loops we simply set the endpoints at the center of source/target node to
// make them smoother and, of course, we cap the total number of waypoints.
waypoints = fromJS(uniformSelect(waypoints.toJS(), EDGE_WAYPOINTS_CAP));
waypoints = waypoints.set(0, sourcePoint);
waypoints = waypoints.set(waypoints.size - 1, targetPoint);
}
return waypoints;
}
/**
* Layout engine runner
* After the layout engine run nodes and edges have x-y-coordinates. Engine is
@@ -102,15 +141,12 @@ function runLayoutEngine(graph, imNodes, imEdges) {
graph.edges().forEach((graphEdge) => {
const graphEdgeMeta = graph.edge(graphEdge);
const edge = edges.get(graphEdgeMeta.id);
let points = fromJS(graphEdgeMeta.points);
// set beginning and end points to node coordinates to ignore node bounding box
const source = nodes.get(fromGraphNodeId(edge.get('source')));
const target = nodes.get(fromGraphNodeId(edge.get('target')));
points = points.mergeIn([0], {x: source.get('x'), y: source.get('y')});
points = points.mergeIn([points.size - 1], {x: target.get('x'), y: target.get('y')});
const waypoints = correctedEdgePath(fromJS(graphEdgeMeta.points), source, target);
edges = edges.setIn([graphEdgeMeta.id, 'points'], points);
edges = edges.setIn([graphEdgeMeta.id, 'points'], waypoints);
});
// return object with the width and height of layout

View File

@@ -31,6 +31,11 @@ export const UNIT_CLOUD_PATH = 'M-1.25 0.233Q-1.25 0.44-1.104 0.587-0.957 0.733-
// are given on a small unit scale as foreign objects in SVG.
export const NODE_BASE_SIZE = 100;
// This value represents the upper bound on the number of control points along the graph edge
// curve. Any integer value >= 6 should result in valid edges, but generally the greater this
// value is, the nicer the edge bundling will be. On the other hand, big values would result
// in slower rendering of the graph.
export const EDGE_WAYPOINTS_CAP = 10;
export const CANVAS_MARGINS = {
[GRAPH_VIEW_MODE]: { top: 160, left: 40, right: 40, bottom: 150 },