@joint/core
Version:
JavaScript diagramming library
857 lines (637 loc) • 28.5 kB
JavaScript
import * as g from '../g/index.mjs';
import * as util from '../util/index.mjs';
import { orthogonal } from './orthogonal.mjs';
var config = {
// size of the step to find a route (the grid of the manhattan pathfinder)
step: 10,
// the number of route finding loops that cause the router to abort
// returns fallback route instead
maximumLoops: 2000,
// the number of decimal places to round floating point coordinates
precision: 1,
// maximum change of direction
maxAllowedDirectionChange: 90,
// should the router use perpendicular linkView option?
// does not connect anchor of element but rather a point close-by that is orthogonal
// this looks much better
perpendicular: true,
// should the source and/or target not be considered as obstacles?
excludeEnds: [], // 'source', 'target'
// should certain types of elements not be considered as obstacles?
excludeTypes: [],
// possible starting directions from an element
startDirections: ['top', 'right', 'bottom', 'left'],
// possible ending directions to an element
endDirections: ['top', 'right', 'bottom', 'left'],
// specify the directions used above and what they mean
directionMap: {
top: { x: 0, y: -1 },
right: { x: 1, y: 0 },
bottom: { x: 0, y: 1 },
left: { x: -1, y: 0 }
},
// cost of an orthogonal step
cost: function() {
return this.step;
},
// an array of directions to find next points on the route
// different from start/end directions
directions: function() {
var step = this.step;
var cost = this.cost();
return [
{ offsetX: step, offsetY: 0, cost: cost },
{ offsetX: -step, offsetY: 0, cost: cost },
{ offsetX: 0, offsetY: step, cost: cost },
{ offsetX: 0, offsetY: -step, cost: cost }
];
},
// a penalty received for direction change
penalties: function() {
return {
0: 0,
45: this.step / 2,
90: this.step / 2
};
},
// padding applied on the element bounding boxes
paddingBox: function() {
var step = this.step;
return {
x: -step,
y: -step,
width: 2 * step,
height: 2 * step
};
},
// A function that determines whether a given point is an obstacle or not.
// If used, the `padding`, `excludeEnds`and `excludeTypes` options are ignored.
// (point: dia.Point) => boolean;
isPointObstacle: null,
// a router to use when the manhattan router fails
// (one of the partial routes returns null)
fallbackRouter: function(vertices, opt, linkView) {
if (!util.isFunction(orthogonal)) {
throw new Error('Manhattan requires the orthogonal router as default fallback.');
}
return orthogonal(vertices, util.assign({}, config, opt), linkView);
},
/* Deprecated */
// a simple route used in situations when main routing method fails
// (exceed max number of loop iterations, inaccessible)
fallbackRoute: function(from, to, opt) {
return null; // null result will trigger the fallbackRouter
// left for reference:
/*// Find an orthogonal route ignoring obstacles.
var point = ((opt.previousDirAngle || 0) % 180 === 0)
? new g.Point(from.x, to.y)
: new g.Point(to.x, from.y);
return [point];*/
},
// if a function is provided, it's used to route the link while dragging an end
// i.e. function(from, to, opt) { return []; }
draggingRoute: null
};
// HELPER CLASSES //
// Map of obstacles
// Helper structure to identify whether a point lies inside an obstacle.
function ObstacleMap(opt) {
this.map = {};
this.options = opt;
// tells how to divide the paper when creating the elements map
this.mapGridSize = 100;
}
ObstacleMap.prototype.build = function(graph, link) {
var opt = this.options;
// source or target element could be excluded from set of obstacles
var excludedEnds = util.toArray(opt.excludeEnds).reduce(function(res, item) {
var end = link.get(item);
if (end) {
var cell = graph.getCell(end.id);
if (cell) {
res.push(cell);
}
}
return res;
}, []);
// Exclude any embedded elements from the source and the target element.
var excludedAncestors = [];
var source = graph.getCell(link.get('source').id);
if (source) {
excludedAncestors = util.union(excludedAncestors, source.getAncestors().map(function(cell) {
return cell.id;
}));
}
var target = graph.getCell(link.get('target').id);
if (target) {
excludedAncestors = util.union(excludedAncestors, target.getAncestors().map(function(cell) {
return cell.id;
}));
}
// Builds a map of all elements for quicker obstacle queries (i.e. is a point contained
// in any obstacle?) (a simplified grid search).
// The paper is divided into smaller cells, where each holds information about which
// elements belong to it. When we query whether a point lies inside an obstacle we
// don't need to go through all obstacles, we check only those in a particular cell.
var mapGridSize = this.mapGridSize;
graph.getElements().reduce(function(map, element) {
var isExcludedType = util.toArray(opt.excludeTypes).includes(element.get('type'));
var isExcludedEnd = excludedEnds.find(function(excluded) {
return excluded.id === element.id;
});
var isExcludedAncestor = excludedAncestors.includes(element.id);
var isExcluded = isExcludedType || isExcludedEnd || isExcludedAncestor;
if (!isExcluded) {
var bbox = element.getBBox().moveAndExpand(opt.paddingBox);
var origin = bbox.origin().snapToGrid(mapGridSize);
var corner = bbox.corner().snapToGrid(mapGridSize);
for (var x = origin.x; x <= corner.x; x += mapGridSize) {
for (var y = origin.y; y <= corner.y; y += mapGridSize) {
var gridKey = x + '@' + y;
map[gridKey] = map[gridKey] || [];
map[gridKey].push(bbox);
}
}
}
return map;
}, this.map);
return this;
};
ObstacleMap.prototype.isPointAccessible = function(point) {
var mapKey = point.clone().snapToGrid(this.mapGridSize).toString();
return util.toArray(this.map[mapKey]).every(function(obstacle) {
return !obstacle.containsPoint(point);
});
};
// Sorted Set
// Set of items sorted by given value.
function SortedSet() {
this.items = [];
this.hash = {};
this.values = {};
this.OPEN = 1;
this.CLOSE = 2;
}
SortedSet.prototype.add = function(item, value) {
if (this.hash[item]) {
// item removal
this.items.splice(this.items.indexOf(item), 1);
} else {
this.hash[item] = this.OPEN;
}
this.values[item] = value;
var index = util.sortedIndex(this.items, item, function(i) {
return this.values[i];
}.bind(this));
this.items.splice(index, 0, item);
};
SortedSet.prototype.remove = function(item) {
this.hash[item] = this.CLOSE;
};
SortedSet.prototype.isOpen = function(item) {
return this.hash[item] === this.OPEN;
};
SortedSet.prototype.isClose = function(item) {
return this.hash[item] === this.CLOSE;
};
SortedSet.prototype.isEmpty = function() {
return this.items.length === 0;
};
SortedSet.prototype.pop = function() {
var item = this.items.shift();
this.remove(item);
return item;
};
// HELPERS //
// return source bbox
function getSourceBBox(linkView, opt) {
// expand by padding box
if (opt && opt.paddingBox) return linkView.sourceBBox.clone().moveAndExpand(opt.paddingBox);
return linkView.sourceBBox.clone();
}
// return target bbox
function getTargetBBox(linkView, opt) {
// expand by padding box
if (opt && opt.paddingBox) return linkView.targetBBox.clone().moveAndExpand(opt.paddingBox);
return linkView.targetBBox.clone();
}
// return source anchor
function getSourceAnchor(linkView, opt) {
if (linkView.sourceAnchor) return linkView.sourceAnchor;
// fallback: center of bbox
var sourceBBox = getSourceBBox(linkView, opt);
return sourceBBox.center();
}
// return target anchor
function getTargetAnchor(linkView, opt) {
if (linkView.targetAnchor) return linkView.targetAnchor;
// fallback: center of bbox
var targetBBox = getTargetBBox(linkView, opt);
return targetBBox.center(); // default
}
// returns a direction index from start point to end point
// corrects for grid deformation between start and end
function getDirectionAngle(start, end, numDirections, grid, opt) {
var quadrant = 360 / numDirections;
var angleTheta = start.theta(fixAngleEnd(start, end, grid, opt));
var normalizedAngle = g.normalizeAngle(angleTheta + (quadrant / 2));
return quadrant * Math.floor(normalizedAngle / quadrant);
}
// helper function for getDirectionAngle()
// corrects for grid deformation
// (if a point is one grid steps away from another in both dimensions,
// it is considered to be 45 degrees away, even if the real angle is different)
// this causes visible angle discrepancies if `opt.step` is much larger than `paper.gridSize`
function fixAngleEnd(start, end, grid, opt) {
var step = opt.step;
var diffX = end.x - start.x;
var diffY = end.y - start.y;
var gridStepsX = diffX / grid.x;
var gridStepsY = diffY / grid.y;
var distanceX = gridStepsX * step;
var distanceY = gridStepsY * step;
return new g.Point(start.x + distanceX, start.y + distanceY);
}
// return the change in direction between two direction angles
function getDirectionChange(angle1, angle2) {
var directionChange = Math.abs(angle1 - angle2);
return (directionChange > 180) ? (360 - directionChange) : directionChange;
}
// fix direction offsets according to current grid
function getGridOffsets(directions, grid, opt) {
var step = opt.step;
util.toArray(opt.directions).forEach(function(direction) {
direction.gridOffsetX = (direction.offsetX / step) * grid.x;
direction.gridOffsetY = (direction.offsetY / step) * grid.y;
});
}
// get grid size in x and y dimensions, adapted to source and target positions
function getGrid(step, source, target) {
return {
source: source.clone(),
x: getGridDimension(target.x - source.x, step),
y: getGridDimension(target.y - source.y, step)
};
}
// helper function for getGrid()
function getGridDimension(diff, step) {
// return step if diff = 0
if (!diff) return step;
var absDiff = Math.abs(diff);
var numSteps = Math.round(absDiff / step);
// return absDiff if less than one step apart
if (!numSteps) return absDiff;
// otherwise, return corrected step
var roundedDiff = numSteps * step;
var remainder = absDiff - roundedDiff;
var stepCorrection = remainder / numSteps;
return step + stepCorrection;
}
// return a clone of point snapped to grid
function snapToGrid(point, grid) {
var source = grid.source;
var snappedX = g.snapToGrid(point.x - source.x, grid.x) + source.x;
var snappedY = g.snapToGrid(point.y - source.y, grid.y) + source.y;
return new g.Point(snappedX, snappedY);
}
// round the point to opt.precision
function round(point, precision) {
return point.round(precision);
}
// snap to grid and then round the point
function align(point, grid, precision) {
return round(snapToGrid(point.clone(), grid), precision);
}
// return a string representing the point
// string is rounded in both dimensions
function getKey(point) {
return point.clone().toString();
}
// return a normalized vector from given point
// used to determine the direction of a difference of two points
function normalizePoint(point) {
return new g.Point(
point.x === 0 ? 0 : Math.abs(point.x) / point.x,
point.y === 0 ? 0 : Math.abs(point.y) / point.y
);
}
// PATHFINDING //
// reconstructs a route by concatenating points with their parents
function reconstructRoute(parents, points, tailPoint, from, to, grid, opt) {
var route = [];
var prevDiff = normalizePoint(to.difference(tailPoint));
// tailPoint is assumed to be aligned already
var currentKey = getKey(tailPoint);
var parent = parents[currentKey];
var point;
while (parent) {
// point is assumed to be aligned already
point = points[currentKey];
var diff = normalizePoint(point.difference(parent));
if (!diff.equals(prevDiff)) {
route.unshift(point);
prevDiff = diff;
}
// parent is assumed to be aligned already
currentKey = getKey(parent);
parent = parents[currentKey];
}
// leadPoint is assumed to be aligned already
var leadPoint = points[currentKey];
var fromDiff = normalizePoint(leadPoint.difference(from));
if (!fromDiff.equals(prevDiff)) {
route.unshift(leadPoint);
}
return route;
}
// heuristic method to determine the distance between two points
function estimateCost(from, endPoints) {
var min = Infinity;
for (var i = 0, len = endPoints.length; i < len; i++) {
var cost = from.manhattanDistance(endPoints[i]);
if (cost < min) min = cost;
}
return min;
}
// find points around the bbox taking given directions into account
// lines are drawn from anchor in given directions, intersections recorded
// if anchor is outside bbox, only those directions that intersect get a rect point
// the anchor itself is returned as rect point (representing some directions)
// (since those directions are unobstructed by the bbox)
function getRectPoints(anchor, bbox, directionList, grid, opt) {
var precision = opt.precision;
var directionMap = opt.directionMap;
var anchorCenterVector = anchor.difference(bbox.center());
var keys = util.isObject(directionMap) ? Object.keys(directionMap) : [];
var dirList = util.toArray(directionList);
var rectPoints = keys.reduce(function(res, key) {
if (dirList.includes(key)) {
var direction = directionMap[key];
// create a line that is guaranteed to intersect the bbox if bbox is in the direction
// even if anchor lies outside of bbox
var endpoint = new g.Point(
anchor.x + direction.x * (Math.abs(anchorCenterVector.x) + bbox.width),
anchor.y + direction.y * (Math.abs(anchorCenterVector.y) + bbox.height)
);
var intersectionLine = new g.Line(anchor, endpoint);
// get the farther intersection, in case there are two
// (that happens if anchor lies next to bbox)
var intersections = intersectionLine.intersect(bbox) || [];
var numIntersections = intersections.length;
var farthestIntersectionDistance;
var farthestIntersection = null;
for (var i = 0; i < numIntersections; i++) {
var currentIntersection = intersections[i];
var distance = anchor.squaredDistance(currentIntersection);
if ((farthestIntersectionDistance === undefined) || (distance > farthestIntersectionDistance)) {
farthestIntersectionDistance = distance;
farthestIntersection = currentIntersection;
}
}
// if an intersection was found in this direction, it is our rectPoint
if (farthestIntersection) {
var point = align(farthestIntersection, grid, precision);
// if the rectPoint lies inside the bbox, offset it by one more step
if (bbox.containsPoint(point)) {
point = align(point.offset(direction.x * grid.x, direction.y * grid.y), grid, precision);
}
// then add the point to the result array
// aligned
res.push(point);
}
}
return res;
}, []);
// if anchor lies outside of bbox, add it to the array of points
if (!bbox.containsPoint(anchor)) {
// aligned
rectPoints.push(align(anchor, grid, precision));
}
return rectPoints;
}
// finds the route between two points/rectangles (`from`, `to`) implementing A* algorithm
// rectangles get rect points assigned by getRectPoints()
function findRoute(from, to, isPointObstacle, opt) {
var precision = opt.precision;
// Get grid for this route.
var sourceAnchor, targetAnchor;
if (from instanceof g.Rect) { // `from` is sourceBBox
sourceAnchor = round(getSourceAnchor(this, opt).clone(), precision);
} else {
sourceAnchor = round(from.clone(), precision);
}
if (to instanceof g.Rect) { // `to` is targetBBox
targetAnchor = round(getTargetAnchor(this, opt).clone(), precision);
} else {
targetAnchor = round(to.clone(), precision);
}
var grid = getGrid(opt.step, sourceAnchor, targetAnchor);
// Get pathfinding points.
var start, end; // aligned with grid by definition
var startPoints, endPoints; // assumed to be aligned with grid already
// set of points we start pathfinding from
if (from instanceof g.Rect) { // `from` is sourceBBox
start = sourceAnchor;
startPoints = getRectPoints(start, from, opt.startDirections, grid, opt);
} else {
start = sourceAnchor;
startPoints = [start];
}
// set of points we want the pathfinding to finish at
if (to instanceof g.Rect) { // `to` is targetBBox
end = targetAnchor;
endPoints = getRectPoints(targetAnchor, to, opt.endDirections, grid, opt);
} else {
end = targetAnchor;
endPoints = [end];
}
// take into account only accessible rect points (those not under obstacles)
startPoints = startPoints.filter(p => !isPointObstacle(p));
endPoints = endPoints.filter(p => !isPointObstacle(p));
// Check that there is an accessible route point on both sides.
// Otherwise, use fallbackRoute().
if (startPoints.length > 0 && endPoints.length > 0) {
// The set of tentative points to be evaluated, initially containing the start points.
// Rounded to nearest integer for simplicity.
var openSet = new SortedSet();
// Keeps reference to actual points for given elements of the open set.
var points = {};
// Keeps reference to a point that is immediate predecessor of given element.
var parents = {};
// Cost from start to a point along best known path.
var costs = {};
for (var i = 0, n = startPoints.length; i < n; i++) {
// startPoint is assumed to be aligned already
var startPoint = startPoints[i];
var key = getKey(startPoint);
openSet.add(key, estimateCost(startPoint, endPoints));
points[key] = startPoint;
costs[key] = 0;
}
var previousRouteDirectionAngle = opt.previousDirectionAngle; // undefined for first route
var isPathBeginning = (previousRouteDirectionAngle === undefined);
// directions
var direction, directionChange;
var directions = opt.directions;
getGridOffsets(directions, grid, opt);
var numDirections = directions.length;
var endPointsKeys = util.toArray(endPoints).reduce(function(res, endPoint) {
// endPoint is assumed to be aligned already
var key = getKey(endPoint);
res.push(key);
return res;
}, []);
// main route finding loop
var loopsRemaining = opt.maximumLoops;
while (!openSet.isEmpty() && loopsRemaining > 0) {
// remove current from the open list
var currentKey = openSet.pop();
var currentPoint = points[currentKey];
var currentParent = parents[currentKey];
var currentCost = costs[currentKey];
var isRouteBeginning = (currentParent === undefined); // undefined for route starts
var isStart = currentPoint.equals(start); // (is source anchor or `from` point) = can leave in any direction
var previousDirectionAngle;
if (!isRouteBeginning) previousDirectionAngle = getDirectionAngle(currentParent, currentPoint, numDirections, grid, opt); // a vertex on the route
else if (!isPathBeginning) previousDirectionAngle = previousRouteDirectionAngle; // beginning of route on the path
else if (!isStart) previousDirectionAngle = getDirectionAngle(start, currentPoint, numDirections, grid, opt); // beginning of path, start rect point
else previousDirectionAngle = null; // beginning of path, source anchor or `from` point
// check if we reached any endpoint
var samePoints = startPoints.length === endPoints.length;
if (samePoints) {
for (var j = 0; j < startPoints.length; j++) {
if (!startPoints[j].equals(endPoints[j])) {
samePoints = false;
break;
}
}
}
var skipEndCheck = (isRouteBeginning && samePoints);
if (!skipEndCheck && (endPointsKeys.indexOf(currentKey) >= 0)) {
opt.previousDirectionAngle = previousDirectionAngle;
return reconstructRoute(parents, points, currentPoint, start, end, grid, opt);
}
// go over all possible directions and find neighbors
for (i = 0; i < numDirections; i++) {
direction = directions[i];
var directionAngle = direction.angle;
directionChange = getDirectionChange(previousDirectionAngle, directionAngle);
// if the direction changed rapidly, don't use this point
// any direction is allowed for starting points
if (!(isPathBeginning && isStart) && directionChange > opt.maxAllowedDirectionChange) continue;
var neighborPoint = align(currentPoint.clone().offset(direction.gridOffsetX, direction.gridOffsetY), grid, precision);
var neighborKey = getKey(neighborPoint);
// Closed points from the openSet were already evaluated.
if (openSet.isClose(neighborKey) || isPointObstacle(neighborPoint)) continue;
// We can only enter end points at an acceptable angle.
if (endPointsKeys.indexOf(neighborKey) >= 0) { // neighbor is an end point
var isNeighborEnd = neighborPoint.equals(end); // (is target anchor or `to` point) = can be entered in any direction
if (!isNeighborEnd) {
var endDirectionAngle = getDirectionAngle(neighborPoint, end, numDirections, grid, opt);
var endDirectionChange = getDirectionChange(directionAngle, endDirectionAngle);
if (endDirectionChange > opt.maxAllowedDirectionChange) continue;
}
}
// The current direction is ok.
var neighborCost = direction.cost;
var neighborPenalty = isStart ? 0 : opt.penalties[directionChange]; // no penalties for start point
var costFromStart = currentCost + neighborCost + neighborPenalty;
if (!openSet.isOpen(neighborKey) || (costFromStart < costs[neighborKey])) {
// neighbor point has not been processed yet
// or the cost of the path from start is lower than previously calculated
points[neighborKey] = neighborPoint;
parents[neighborKey] = currentPoint;
costs[neighborKey] = costFromStart;
openSet.add(neighborKey, costFromStart + estimateCost(neighborPoint, endPoints));
}
}
loopsRemaining--;
}
}
// no route found (`to` point either wasn't accessible or finding route took
// way too much calculation)
return opt.fallbackRoute.call(this, start, end, opt);
}
// resolve some of the options
function resolveOptions(opt) {
opt.directions = util.result(opt, 'directions');
opt.penalties = util.result(opt, 'penalties');
opt.paddingBox = util.result(opt, 'paddingBox');
opt.padding = util.result(opt, 'padding');
if (opt.padding) {
// if both provided, opt.padding wins over opt.paddingBox
var sides = util.normalizeSides(opt.padding);
opt.paddingBox = {
x: -sides.left,
y: -sides.top,
width: sides.left + sides.right,
height: sides.top + sides.bottom
};
}
util.toArray(opt.directions).forEach(function(direction) {
var point1 = new g.Point(0, 0);
var point2 = new g.Point(direction.offsetX, direction.offsetY);
direction.angle = g.normalizeAngle(point1.theta(point2));
});
}
// initialization of the route finding
function router(vertices, opt, linkView) {
resolveOptions(opt);
// enable/disable linkView perpendicular option
linkView.options.perpendicular = !!opt.perpendicular;
var sourceBBox = getSourceBBox(linkView, opt);
var targetBBox = getTargetBBox(linkView, opt);
var sourceAnchor = getSourceAnchor(linkView, opt);
//var targetAnchor = getTargetAnchor(linkView, opt);
// pathfinding
let isPointObstacle;
if (typeof opt.isPointObstacle === 'function') {
isPointObstacle = opt.isPointObstacle;
} else {
const map = new ObstacleMap(opt);
map.build(linkView.paper.model, linkView.model);
isPointObstacle = (point) => !map.isPointAccessible(point);
}
var oldVertices = util.toArray(vertices).map(g.Point);
var newVertices = [];
var tailPoint = sourceAnchor; // the origin of first route's grid, does not need snapping
// find a route by concatenating all partial routes (routes need to pass through vertices)
// source -> vertex[1] -> ... -> vertex[n] -> target
var to, from;
for (var i = 0, len = oldVertices.length; i <= len; i++) {
var partialRoute = null;
from = to || sourceBBox;
to = oldVertices[i];
if (!to) {
// this is the last iteration
// we ran through all vertices in oldVertices
// 'to' is not a vertex.
to = targetBBox;
// If the target is a point (i.e. it's not an element), we
// should use dragging route instead of main routing method if it has been provided.
var isEndingAtPoint = !linkView.model.get('source').id || !linkView.model.get('target').id;
if (isEndingAtPoint && util.isFunction(opt.draggingRoute)) {
// Make sure we are passing points only (not rects).
var dragFrom = (from === sourceBBox) ? sourceAnchor : from;
var dragTo = to.origin();
partialRoute = opt.draggingRoute.call(linkView, dragFrom, dragTo, opt);
}
}
// if partial route has not been calculated yet use the main routing method to find one
partialRoute = partialRoute || findRoute.call(linkView, from, to, isPointObstacle, opt);
if (partialRoute === null) { // the partial route cannot be found
return opt.fallbackRouter(vertices, opt, linkView);
}
var leadPoint = partialRoute[0];
// remove the first point if the previous partial route had the same point as last
if (leadPoint && leadPoint.equals(tailPoint)) partialRoute.shift();
// save tailPoint for next iteration
tailPoint = partialRoute[partialRoute.length - 1] || tailPoint;
Array.prototype.push.apply(newVertices, partialRoute);
}
return newVertices;
}
// public function
export const manhattan = function(vertices, opt, linkView) {
return router(vertices, util.assign({}, config, opt), linkView);
};