@antv/g6
Version:
A Graph Visualization Framework in JavaScript
471 lines (402 loc) • 15.3 kB
text/typescript
import { isNumber } from '@antv/util';
import type { Direction, ID, Node, Point, ShortestPathRouterOptions } from '../../types';
import { getBBoxSegments, getBBoxSize, getExpandedBBox, isPointInBBox, isPointOnBBoxBoundary } from '../bbox';
import { getLinesIntersection } from '../line';
import { add, manhattanDistance, toVector2 } from '../vector';
const defaultCfg: ShortestPathRouterOptions = {
enableObstacleAvoidance: false,
offset: 10,
maxAllowedDirectionChange: Math.PI / 2,
maximumLoops: 3000,
gridSize: 5,
startDirections: ['top', 'right', 'bottom', 'left'],
endDirections: ['top', 'right', 'bottom', 'left'],
directionMap: {
right: { stepX: 1, stepY: 0 },
left: { stepX: -1, stepY: 0 },
bottom: { stepX: 0, stepY: 1 },
top: { stepX: 0, stepY: -1 },
},
penalties: { 0: 0, 90: 0 },
distFunc: manhattanDistance,
};
const keyOf = (point: Point) => `${Math.round(point[0])}|||${Math.round(point[1])}`;
function alignToGrid(p: Point, gridSize: number): Point;
function alignToGrid(p: number, gridSize: number): number;
/**
* <zh/> 将坐标对齐到网格
*
* <en/> Align to the grid
* @param p - <zh/> 坐标 | <en/> point
* @param gridSize - <zh/> 网格大小 | <en/> grid size
* @returns <zh/> 对齐后的坐标 | <en/> aligned point
*/
function alignToGrid(p: number | Point, gridSize: number): number | Point {
const align = (value: number) => Math.round(value / gridSize);
if (isNumber(p)) return align(p);
return p.map(align) as Point;
}
/**
* <zh/> 获取两个角度的变化方向,并确保小于 180 度
*
* <en/ >Get changed direction angle and make sure less than 180 degrees
* @param angle1 - <zh/> 第一个角度 | <en/> the first angle
* @param angle2 - <zh/> 第二个角度 | <en/> the second angle
* @returns <zh/> 两个角度的变化方向 | <en/> changed direction angle
*/
function getAngleDiff(angle1: number, angle2: number) {
const directionChange = Math.abs(angle1 - angle2);
return directionChange > Math.PI ? 2 * Math.PI - directionChange : directionChange;
}
/**
* <zh/> 获取从 p1 指向 p2 的向量与 x 轴正方向之间的夹角,单位为弧度
*
* <en/> Get the angle between the vector from p1 to p2 and the positive direction of the x-axis, in radians
* @param p1 - <zh/> 点 p1 | <en/> point p1
* @param p2 - <zh/> 点 p2 | <en/> point p2
* @returns <zh/> 夹角 | <en/> angle
*/
function getDirectionAngle(p1: Point, p2: Point) {
const deltaX = p2[0] - p1[0];
const deltaY = p2[1] - p1[1];
if (!deltaX && !deltaY) return 0;
return Math.atan2(deltaY, deltaX);
}
/**
* <zh/> 获取两个点之间的方向变化
*
* <en/> Get direction change between two points
* @param current - <zh/> 当前点 | <en/> current point
* @param neighbor - <zh/> 邻居点 | <en/> neighbor point
* @param cameFrom - <zh/> 来源点 | <en/> source point
* @param scaleStartPoint - <zh/> 缩放后的起点 | <en/> scaled start point
* @returns <zh/> 方向变化 | <en/> direction change
*/
function getDirectionChange(
current: Point,
neighbor: Point,
cameFrom: Record<string, Point>,
scaleStartPoint: Point,
): number {
const directionAngle = getDirectionAngle(current, neighbor);
const currentCameFrom = cameFrom[keyOf(current)];
const prev = !currentCameFrom ? scaleStartPoint : currentCameFrom;
const prevDirectionAngle = getDirectionAngle(prev, current);
return getAngleDiff(prevDirectionAngle, directionAngle);
}
/**
* <zh/> 获取障碍物地图
*
* <en/> Get obstacle map
* @param nodes - <zh/> 图上所有节点 | <en/> all nodes on the graph
* @param options - <zh/> 路由配置 | <en/> router options
* @returns <zh/> 障碍物地图 | <en/> obstacle map
*/
const getObstacleMap = (nodes: Node[], options: Required<ShortestPathRouterOptions>) => {
const { offset, gridSize } = options;
const obstacleMap: Record<string, boolean> = {};
nodes.forEach((item: Node) => {
if (!item || item.destroyed || !item.isVisible()) return;
const bbox = getExpandedBBox(item.getRenderBounds(), offset);
for (let x = alignToGrid(bbox.min[0], gridSize); x <= alignToGrid(bbox.max[0], gridSize); x += 1) {
for (let y = alignToGrid(bbox.min[1], gridSize); y <= alignToGrid(bbox.max[1], gridSize); y += 1) {
obstacleMap[`${x}|||${y}`] = true;
}
}
});
return obstacleMap;
};
/**
* <zh/> 估算从起点到多个锚点的最小代价
*
* <en/> Estimate minimum cost from the starting point to multiple anchor points
* @param from - <zh/> 起点 | <en/> source point
* @param anchors - <zh/> 锚点 | <en/> anchor points
* @param distFunc - <zh/> 距离函数 | <en/> distance function
* @returns <zh/> 最小成本 | <en/> minimum cost
*/
export function estimateCost(from: Point, anchors: Point[], distFunc: (p1: Point, p2: Point) => number) {
return Math.min(...anchors.map((anchor) => distFunc(from, anchor)));
}
/**
* <zh/> 已知一个点集与一个参考点,从点集中找到距离参考点最近的点
*
* <en/> Given a set of points and a reference point, find the point closest to the reference point from the set of points
* @param points - <zh/> 点集 | <en/> set of points
* @param refPoint - <zh/> 参考点 | <en/> reference point
* @param distFunc - <zh/> 距离函数 | <en/> distance function
* @returns <zh/> 最近的点 | <en/> nearest point
*/
export function getNearestPoint(points: Point[], refPoint: Point, distFunc: (p1: Point, p2: Point) => number): Point {
let nearestPoint = points[0];
let minDistance = distFunc(points[0], refPoint);
for (let i = 0; i < points.length; i++) {
const point = points[i];
const dis = distFunc(point, refPoint);
if (dis < minDistance) {
nearestPoint = point;
minDistance = dis;
}
}
return nearestPoint;
}
/**
* Calculate the connection points on the expanded BBox
* @param point
* @param node
* @param directions
* @param options
*/
const getBoxPoints = (
point: Point,
node: Node,
directions: Direction[],
options: Required<ShortestPathRouterOptions>,
): Point[] => {
// create-edge 生成边的过程中,endNode 为 null
if (!node) return [point];
const { directionMap, offset } = options;
const expandedBBox = getExpandedBBox(node.getRenderBounds(), offset);
const points = (Object.keys(directionMap) as Direction[]).reduce<Point[]>((res, directionKey) => {
if (directions.includes(directionKey)) {
const direction = directionMap[directionKey];
const [width, height] = getBBoxSize(expandedBBox);
const otherPoint: Point = [point[0] + direction.stepX * width, point[1] + direction.stepY * height];
const segments = getBBoxSegments(expandedBBox);
for (let i = 0; i < segments.length; i++) {
const intersectP = getLinesIntersection([point, otherPoint], segments[i]);
if (intersectP && isPointOnBBoxBoundary(intersectP, expandedBBox)) {
res.push(intersectP);
}
}
}
return res;
}, []);
if (!isPointInBBox(point, expandedBBox)) {
points.push(point);
}
return points.map((point) => alignToGrid(point, options.gridSize));
};
const getControlPoints = (
current: Point,
cameFrom: Record<ID, Point>,
scaleStartPoint: Point,
endPoint: Point,
startPoints: Point[],
scaleEndPoint: Point,
gridSize: number,
) => {
const controlPoints: Point[] = [];
// append endPoint
let pointZero: Point = [
scaleEndPoint[0] === endPoint[0] ? endPoint[0] : current[0] * gridSize,
scaleEndPoint[1] === endPoint[1] ? endPoint[1] : current[1] * gridSize,
];
controlPoints.unshift(pointZero);
let _current = current;
let _currentCameFrom = cameFrom[keyOf(_current)];
while (_currentCameFrom) {
const prePoint = _currentCameFrom;
const point = _current;
const directionChange = getDirectionChange(prePoint, point, cameFrom, scaleStartPoint);
if (directionChange) {
pointZero = [
prePoint[0] === point[0] ? pointZero[0] : prePoint[0] * gridSize,
prePoint[1] === point[1] ? pointZero[1] : prePoint[1] * gridSize,
];
controlPoints.unshift(pointZero);
}
_currentCameFrom = cameFrom[keyOf(prePoint)];
_current = prePoint;
}
// append startPoint
const realStartPoints = startPoints.map((point) => [point[0] * gridSize, point[1] * gridSize] as Point);
const startPoint = getNearestPoint(realStartPoints, pointZero, manhattanDistance);
controlPoints.unshift(startPoint);
return controlPoints;
};
/**
* Find the shortest path between a given source node to a destination node according to A* Search Algorithm:https://www.geeksforgeeks.org/a-search-algorithm/
* @param sourceNode - <zh/> 源节点 | <en/> source node
* @param targetNode - <zh/> 目标节点 | <en/> target node
* @param nodes - <zh/> 图上所有节点 | <en/> all nodes on the graph
* @param config - <zh/> 路由配置 | <en/> router options
* @returns <zh/> 控制点数组 | <en/> control point array
*/
export function aStarSearch(
sourceNode: Node,
targetNode: Node,
nodes: Node[],
config: ShortestPathRouterOptions,
): Point[] {
const startPoint = toVector2(sourceNode.getCenter());
const endPoint = toVector2(targetNode.getCenter());
const options = Object.assign(defaultCfg, config) as Required<ShortestPathRouterOptions>;
const { gridSize } = options;
const obstacles = options.enableObstacleAvoidance ? nodes : [sourceNode, targetNode];
const obstacleMap = getObstacleMap(obstacles, options);
const scaleStartPoint = alignToGrid(startPoint, gridSize);
const scaleEndPoint = alignToGrid(endPoint, gridSize);
const startPoints = getBoxPoints(startPoint, sourceNode, options.startDirections, options);
const endPoints = getBoxPoints(endPoint, targetNode, options.endDirections, options);
startPoints.forEach((point) => delete obstacleMap[keyOf(point)]);
endPoints.forEach((point) => delete obstacleMap[keyOf(point)]);
const openList: Record<string, Point> = {};
const closedList: Record<string, boolean> = {};
const cameFrom: Record<string, Point> = {};
// The movement cost to move from the starting point to the current point on the grid.
const gScore: Record<string, number> = {};
// The estimated movement cost to move from the starting point to the end point after passing through the current point.
// f = g + h
const fScore: Record<string, number> = {};
const sortedOpenSet = new SortedArray();
for (let i = 0; i < startPoints.length; i++) {
const firstStep = startPoints[i];
const key = keyOf(firstStep);
openList[key] = firstStep;
gScore[key] = 0;
fScore[key] = estimateCost(firstStep, endPoints, options.distFunc);
// Push start point to sortedOpenSet
sortedOpenSet.add({
id: key,
value: fScore[key],
});
}
const endPointsKeys = endPoints.map((point) => keyOf(point));
let remainLoops = options.maximumLoops;
let current: Point;
let curCost = Infinity;
for (const [id, value] of Object.entries(openList)) {
if (fScore[id] <= curCost) {
curCost = fScore[id];
current = value;
}
}
while (Object.keys(openList).length > 0 && remainLoops > 0) {
const minId = sortedOpenSet.minId(false);
if (minId) {
current = openList[minId];
} else {
break;
}
const key = keyOf(current);
// If currentNode is final, return the successful path
if (endPointsKeys.includes(key)) {
return getControlPoints(current, cameFrom, scaleStartPoint, endPoint, startPoints, scaleEndPoint, gridSize);
}
// Set currentNode as closed
delete openList[key];
sortedOpenSet.remove(key);
closedList[key] = true;
// Get the neighbor points of the next step
for (const dir of Object.values(options.directionMap)) {
const neighbor = add(current, [dir.stepX, dir.stepY]);
const neighborId = keyOf(neighbor);
if (closedList[neighborId]) continue;
const directionChange = getDirectionChange(current, neighbor, cameFrom, scaleStartPoint);
if (directionChange > options.maxAllowedDirectionChange) continue;
if (obstacleMap[neighborId]) continue; // skip if intersects
// Add neighbor points to openList, and calculate the cost of each neighbor point
if (!openList[neighborId]) {
openList[neighborId] = neighbor;
}
const directionPenalties = options.penalties[directionChange];
const neighborCost =
options.distFunc(current, neighbor) + (isNaN(directionPenalties) ? gridSize : directionPenalties);
const costFromStart = gScore[key] + neighborCost;
const neighborGScore = gScore[neighborId];
if (neighborGScore && costFromStart >= neighborGScore) continue;
cameFrom[neighborId] = current;
gScore[neighborId] = costFromStart;
fScore[neighborId] = costFromStart + estimateCost(neighbor, endPoints, options.distFunc);
sortedOpenSet.add({
id: neighborId,
value: fScore[neighborId],
});
}
remainLoops -= 1;
}
return [];
}
type Item = {
id: string;
value: number;
};
/**
* <zh/> 有序数组,按升序排列
*
* <en/> Sorted array, sorted in ascending order
*/
export class SortedArray {
public arr: Item[] = [];
private map: Record<string, boolean> = {};
constructor() {
this.arr = [];
this.map = {};
}
private _innerAdd(item: Item, length: number) {
let low = 0,
high = length - 1;
while (high - low > 1) {
const mid = Math.floor((low + high) / 2);
if (this.arr[mid].value > item.value) {
high = mid;
} else if (this.arr[mid].value < item.value) {
low = mid;
} else {
this.arr.splice(mid, 0, item);
this.map[item.id] = true;
return;
}
}
this.arr.splice(high, 0, item);
this.map[item.id] = true;
}
/**
* <zh/> 将新项添加到适当的索引位置
*
* <en/> Add the new item to the appropriate index
* @param item - <zh/> 新项 | <en/> new item
*/
public add(item: Item) {
// 已经存在,先移除
// If exists, remove it
delete this.map[item.id];
const length = this.arr.length;
// 如果为空或者最后一个元素小于当前元素,直接添加到最后
// If empty or the last element is less than the current element, add to the end
if (!length || this.arr[length - 1].value < item.value) {
this.arr.push(item);
this.map[item.id] = true;
return;
}
// 按照升序排列,找到合适的位置插入
// Find the appropriate position to insert in ascending order
this._innerAdd(item, length);
}
public remove(id: string) {
if (!this.map[id]) return;
delete this.map[id];
}
private _clearAndGetMinId() {
let res;
for (let i = this.arr.length - 1; i >= 0; i--) {
if (this.map[this.arr[i].id]) res = this.arr[i].id;
else this.arr.splice(i, 1);
}
return res;
}
private _findFirstId() {
while (this.arr.length) {
const first = this.arr.shift()!;
if (this.map[first.id]) return first.id;
}
}
public minId(clear: boolean) {
if (clear) {
return this._clearAndGetMinId();
} else {
return this._findFirstId();
}
}
}