import type { AABB, DisplayObject, TransformArray } from '@antv/g'; import type { PathArray } from '@antv/util'; import { isEqual, isNumber } from '@antv/util'; import type { EdgeData } from '../spec'; import type { EdgeBadgeStyleProps, EdgeKey, EdgeLabelStyleProps, ID, LoopPlacement, Node, NodeLikeData, Point, Port, Size, Vector2, } from '../types'; import { getBBoxHeight, getBBoxSize, getBBoxWidth, getNearestBoundarySide, getNodeBBox } from './bbox'; import { isCollapsed } from './collapsibility'; import { getAllPorts, getNodeConnectionPoint, getPortConnectionPoint, getPortPosition } from './element'; import { idOf } from './id'; import { isCollinear, isHorizontal, moveTo, parsePoint } from './point'; import { freeJoin } from './router/orth'; import { add, distance, manhattanDistance, multiply, normalize, perpendicular, subtract } from './vector'; /** * 获取标签的位置样式 * * Get the style of the label's position * @param key - 边对象 | The edge object * @param placement - 标签位置 | Position of the label * @param autoRotate - 是否自动旋转 | Whether to auto-rotate * @param offsetX - 标签相对于边的水平偏移量 | Horizontal offset of the label relative to the edge * @param offsetY - 标签相对于边的垂直偏移量 | Vertical offset of the label relative to the edge * @returns 标签的位置样式 | Returns the style of the label's position */ export function getLabelPositionStyle( key: EdgeKey, placement: EdgeLabelStyleProps['placement'], autoRotate: boolean, offsetX: number, offsetY: number, ): Partial { const START_RATIO = 0; const MIDDLE_RATIO = 0.5; const END_RATIO = 0.99; let ratio = typeof placement === 'number' ? placement : MIDDLE_RATIO; if (placement === 'start') ratio = START_RATIO; if (placement === 'end') ratio = END_RATIO; const point = parsePoint(key.getPoint(ratio)); const pointOffset = parsePoint(key.getPoint(ratio + 0.01)); let textAlign: 'left' | 'right' | 'center' = placement === 'start' ? 'left' : placement === 'end' ? 'right' : 'center'; if (isHorizontal(point, pointOffset) || !autoRotate) { const [x, y] = getXYByPlacement(key, ratio, offsetX, offsetY); return { transform: [['translate', x, y]], textAlign }; } let angle = Math.atan2(pointOffset[1] - point[1], pointOffset[0] - point[0]); const isRevert = pointOffset[0] < point[0]; if (isRevert) { textAlign = textAlign === 'center' ? textAlign : textAlign === 'left' ? 'right' : 'left'; offsetX! *= -1; angle += Math.PI; } const [x, y] = getXYByPlacement(key, ratio, offsetX, offsetY, angle); const transform: TransformArray = [ ['translate', x, y], ['rotate', (angle / Math.PI) * 180], ]; return { textAlign, transform, }; } /** * 获取边上徽标的位置样式 * * Get the position style of the badge on the edge * @param shapeMap - 边上的图形映射 | Shape map on the edge * @param placement - 徽标位置 | Badge position * @param labelPlacement - 标签位置 | Label position * @param offsetX - 水平偏移量 | Horizontal offset * @param offsetY - 垂直偏移量 | Vertical offset * @returns 徽标的位置样式 | Position style of the badge */ export function getBadgePositionStyle( shapeMap: Record>, placement: EdgeBadgeStyleProps['placement'], labelPlacement: EdgeLabelStyleProps['placement'], offsetX: number, offsetY: number, ) { const badgeWidth = shapeMap.badge?.getGeometryBounds().halfExtents[0] * 2 || 0; const labelWidth = shapeMap.label?.getGeometryBounds().halfExtents[0] * 2 || 0; return getLabelPositionStyle( shapeMap.key as EdgeKey, labelPlacement, true, (labelWidth ? (labelWidth / 2 + badgeWidth / 2) * (placement === 'suffix' ? 1 : -1) : 0) + offsetX, offsetY, ); } /** * 获取给定边上的指定位置的坐标 * * Get the coordinates at the specified position on the given edge * @param key - 边实例 | Edge instance * @param ratio - 位置比率 | Position ratio * @param offsetX - 水平偏移量 | Horizontal offset * @param offsetY - 垂直偏移量 | Vertical offset * @param angle - 旋转角度 | Rotation angle * @returns 坐标 | Coordinates */ function getXYByPlacement(key: EdgeKey, ratio: number, offsetX: number, offsetY: number, angle?: number) { const [pointX, pointY] = parsePoint(key.getPoint(ratio)); let actualOffsetX = offsetX; let actualOffsetY = offsetY; if (angle) { actualOffsetX = offsetX * Math.cos(angle) - offsetY * Math.sin(angle); actualOffsetY = offsetX * Math.sin(angle) + offsetY * Math.cos(angle); } return [pointX + actualOffsetX, pointY + actualOffsetY]; } /** ==================== Curve Edge =========================== */ /** * 计算曲线的控制点 * * Calculate the control point of the curve * @param sourcePoint - 起点 | Source point * @param targetPoint - 终点 | Target point * @param curvePosition - 控制点在连线上的相对位置(取值范围为 0-1) | The relative position of the control point on the line (value range from 0 to 1) * @param curveOffset - 控制点距离两端点连线的距离 | The distance between the control point and the line * @returns 控制点 | Control points */ export function getCurveControlPoint( sourcePoint: Point, targetPoint: Point, curvePosition: number, curveOffset: number, ): Point { if (isEqual(sourcePoint, targetPoint)) return sourcePoint; const lineVector = subtract(targetPoint, sourcePoint); const controlPoint: Point = [ sourcePoint[0] + curvePosition * lineVector[0], sourcePoint[1] + curvePosition * lineVector[1], ]; const perpVector = normalize(perpendicular(lineVector as Vector2, false)); controlPoint[0] += curveOffset * perpVector[0]; controlPoint[1] += curveOffset * perpVector[1]; return controlPoint; } /** * 解析控制点距离两端点连线的距离 `curveOffset` * * parse the distance of the control point from the line `curveOffset` * @param curveOffset - curveOffset | curveOffset * @returns 标准 curveOffset | standard curveOffset */ export function parseCurveOffset(curveOffset: number | [number, number]): [number, number] { if (isNumber(curveOffset)) return [curveOffset, -curveOffset]; return curveOffset; } /** * 解析控制点在两端点连线上的相对位置 `curvePosition`,范围为`0-1` * * parse the relative position of the control point on the line `curvePosition` * @param curvePosition - curvePosition | curvePosition * @returns 标准 curvePosition | standard curvePosition */ export function parseCurvePosition(curvePosition: number | [number, number]): [number, number] { if (isNumber(curvePosition)) return [curvePosition, 1 - curvePosition]; return curvePosition; } /** * 获取二次贝塞尔曲线绘制路径 * * Calculate the path for drawing a quadratic Bessel curve * @param sourcePoint - 边的起点 | Source point * @param targetPoint - 边的终点 | Target point * @param controlPoint - 控制点 | Control point * @returns 返回绘制曲线的路径 | Returns curve path */ export function getQuadraticPath(sourcePoint: Point, targetPoint: Point, controlPoint: Point): PathArray { return [ ['M', sourcePoint[0], sourcePoint[1]], ['Q', controlPoint[0], controlPoint[1], targetPoint[0], targetPoint[1]], ]; } /** * 获取三次贝塞尔曲线绘制路径 * * Calculate the path for drawing a cubic Bessel curve * @param sourcePoint - 边的起点 | Source point * @param targetPoint - 边的终点 | Target point * @param controlPoints - 控制点 | Control point * @returns 返回绘制曲线的路径 | Returns curve path */ export function getCubicPath(sourcePoint: Point, targetPoint: Point, controlPoints: [Point, Point]): PathArray { return [ ['M', sourcePoint[0], sourcePoint[1]], [ 'C', controlPoints[0][0], controlPoints[0][1], controlPoints[1][0], controlPoints[1][1], targetPoint[0], targetPoint[1], ], ]; } /** ==================== Polyline Edge =========================== */ /** * 获取折线的绘制路径 * * Calculates the path for drawing a polyline * @param points - 折线的顶点 | The vertices of the polyline * @param radius - 圆角半径 | Radius of the rounded corner * @param z - 路径是否闭合 | Whether the path is closed * @returns 返回绘制折线的路径 | Returns the path for drawing a polyline */ export function getPolylinePath(points: Point[], radius = 0, z = false): PathArray { const targetIndex = points.length - 1; const sourcePoint = points[0]; const targetPoint = points[targetIndex]; const controlPoints = points.slice(1, targetIndex); const pathArray: PathArray = [['M', sourcePoint[0], sourcePoint[1]]]; controlPoints.forEach((midPoint, i) => { const prevPoint = controlPoints[i - 1] || sourcePoint; const nextPoint = controlPoints[i + 1] || targetPoint; if (!isCollinear(prevPoint, midPoint, nextPoint) && radius) { const [ps, pt] = getBorderRadiusPoints(prevPoint, midPoint, nextPoint, radius); pathArray.push(['L', ps[0], ps[1]], ['Q', midPoint[0], midPoint[1], pt[0], pt[1]], ['L', pt[0], pt[1]]); } else { pathArray.push(['L', midPoint[0], midPoint[1]]); } }); pathArray.push(['L', targetPoint[0], targetPoint[1]]); if (z) pathArray.push(['Z']); return pathArray; } /** * 根据给定的半径计算出不共线的三点生成贝塞尔曲线的控制点,以模拟接近圆弧 * * Calculates the control points of the Bezier curve generated by three non-collinear points according to the given radius to simulate an arc * @param prevPoint - 前一个点 | Previous point * @param midPoint - 中间点 | Middle point * @param nextPoint - 后一个点 | Next point * @param radius - 圆角半径 | Radius of the rounded corner * @returns 返回控制点 | Returns control points */ export function getBorderRadiusPoints( prevPoint: Point, midPoint: Point, nextPoint: Point, radius: number, ): [Point, Point] { const d0 = manhattanDistance(prevPoint, midPoint); const d1 = manhattanDistance(nextPoint, midPoint); // 取给定的半径和最小半径之间的较小值 | use the smaller value between the given radius and the minimum radius const r = Math.min(radius, Math.min(d0, d1) / 2); const ps: Point = [ midPoint[0] - (r / d0) * (midPoint[0] - prevPoint[0]), midPoint[1] - (r / d0) * (midPoint[1] - prevPoint[1]), ]; const pt: Point = [ midPoint[0] - (r / d1) * (midPoint[0] - nextPoint[0]), midPoint[1] - (r / d1) * (midPoint[1] - nextPoint[1]), ]; return [ps, pt]; } /** ==================== Loop Edge =========================== */ export const getRadians = (bbox: AABB): Record => { const halfPI = Math.PI / 2; const halfHeight = getBBoxHeight(bbox) / 2; const halfWidth = getBBoxWidth(bbox) / 2; const angleWithX = Math.atan2(halfHeight, halfWidth) / 2; const angleWithY = Math.atan2(halfWidth, halfHeight) / 2; return { top: [-halfPI - angleWithY, -halfPI + angleWithY], 'top-right': [-halfPI + angleWithY, -angleWithX], 'right-top': [-halfPI + angleWithY, -angleWithX], right: [-angleWithX, angleWithX], 'bottom-right': [angleWithX, halfPI - angleWithY], 'right-bottom': [angleWithX, halfPI - angleWithY], bottom: [halfPI - angleWithY, halfPI + angleWithY], 'bottom-left': [halfPI + angleWithY, Math.PI - angleWithX], 'left-bottom': [halfPI + angleWithY, Math.PI - angleWithX], left: [Math.PI - angleWithX, Math.PI + angleWithX], 'top-left': [Math.PI + angleWithX, -halfPI - angleWithY], 'left-top': [Math.PI + angleWithX, -halfPI - angleWithY], }; }; /** * 获取环形边的起点和终点 * * Get the start and end points of the loop edge * @param node - 节点实例 | Node instance * @param placement - 环形边相对于节点位置 | Loop position relative to the node * @param clockwise - 是否顺时针 | Whether to draw the loop clockwise * @param sourcePort - 起点连接桩 | Source port * @param targetPort - 终点连接桩 | Target port * @returns 起点和终点 | Start and end points */ export function getLoopEndpoints( node: Node, placement: LoopPlacement, clockwise: boolean, sourcePort?: Port, targetPort?: Port, ): [Point, Point] { const bbox = getNodeBBox(node); const center = node.getCenter(); let sourcePoint = sourcePort && getPortPosition(sourcePort); let targetPoint = targetPort && getPortPosition(targetPort); if (!sourcePoint || !targetPoint) { const radians = getRadians(bbox); const angle1 = radians[placement][0]; const angle2 = radians[placement][1]; const [width, height] = getBBoxSize(bbox); const r = Math.max(width, height); const point1: Point = add(center, [r * Math.cos(angle1), r * Math.sin(angle1), 0]); const point2: Point = add(center, [r * Math.cos(angle2), r * Math.sin(angle2), 0]); sourcePoint = getNodeConnectionPoint(node, point1); targetPoint = getNodeConnectionPoint(node, point2); if (!clockwise) { [sourcePoint, targetPoint] = [targetPoint, sourcePoint]; } } return [sourcePoint, targetPoint]; } /** * 获取环形边的绘制路径 * * Get the path of the loop edge * @param node - 节点实例 | Node instance * @param placement - 环形边相对于节点位置 | Loop position relative to the node * @param clockwise - 是否顺时针 | Whether to draw the loop clockwise * @param dist - 从节点 keyShape 边缘到自环顶部的距离 | The distance from the edge of the node keyShape to the top of the self-loop * @param sourcePortKey - 起点连接桩 key | Source port key * @param targetPortKey - 终点连接桩 key | Target port key * @returns 返回绘制环形边的路径 | Returns the path of the loop edge */ export function getCubicLoopPath( node: Node, placement: LoopPlacement, clockwise: boolean, dist: number, sourcePortKey?: string, targetPortKey?: string, ) { const sourcePort = node.getPorts()[(sourcePortKey || targetPortKey)!]; const targetPort = node.getPorts()[(targetPortKey || sourcePortKey)!]; // 1. 获取起点和终点 | Get the start and end points let [sourcePoint, targetPoint] = getLoopEndpoints(node, placement, clockwise, sourcePort, targetPort); // 2. 获取控制点 | Get the control points const controlPoints = getCubicLoopControlPoints(node, sourcePoint, targetPoint, dist); // 3. 如果定义了连接桩,调整端点以与连接桩边界相交 | If the port is defined, adjust the endpoint to intersect with the port boundary if (sourcePort) sourcePoint = getPortConnectionPoint(sourcePort, controlPoints[0]); if (targetPort) targetPoint = getPortConnectionPoint(targetPort, controlPoints.at(-1) as Point); return getCubicPath(sourcePoint, targetPoint, controlPoints); } /** * 获取环形边的控制点 * * Get the control points of the loop edge * @param node - 节点实例 | Node instance * @param sourcePoint - 起点 | Source point * @param targetPoint - 终点 | Target point * @param dist - 从节点 keyShape 边缘到自环顶部的距离 | The distance from the edge of the node keyShape to the top of the self-loop * @returns 控制点 | Control points */ export function getCubicLoopControlPoints( node: Node, sourcePoint: Point, targetPoint: Point, dist: number, ): [Point, Point] { const center = node.getCenter(); if (isEqual(sourcePoint, targetPoint)) { const direction = subtract(sourcePoint, center); const adjustment: Point = [ dist * Math.sign(direction[0]) || dist / 2, dist * Math.sign(direction[1]) || -dist / 2, 0, ]; return [add(sourcePoint, adjustment), add(targetPoint, multiply(adjustment, [1, -1, 1]))]; } return [ moveTo(center, sourcePoint, distance(center, sourcePoint) + dist), moveTo(center, targetPoint, distance(center, targetPoint) + dist), ]; } /** * 获取环形折线边的绘制路径 * * Get the path of the loop polyline edge * @param node - 节点实例 | Node instance * @param radius - 圆角半径 | Radius of the rounded corner * @param placement - 环形边相对于节点位置 | Loop position relative to the node * @param clockwise - 是否顺时针 | Whether to draw the loop clockwise * @param dist - 从节点 keyShape 边缘到自环顶部的距离 | The distance from the edge of the node keyShape to the top of the self-loop * @param sourcePortKey - 起点连接桩 key | Source port key * @param targetPortKey - 终点连接桩 key | Target port key * @returns 返回绘制环形折线边的路径 | Returns the path of the loop polyline edge */ export function getPolylineLoopPath( node: Node, radius: number, placement: LoopPlacement, clockwise: boolean, dist: number, sourcePortKey?: string, targetPortKey?: string, ) { const allPortsMap = getAllPorts(node); const sourcePort = allPortsMap[(sourcePortKey || targetPortKey)!]; const targetPort = allPortsMap[(targetPortKey || sourcePortKey)!]; // 1. 获取起点和终点 | Get the start and end points let [sourcePoint, targetPoint] = getLoopEndpoints(node, placement, clockwise, sourcePort, targetPort); // 2. 获取控制点 | Get the control points const controlPoints = getPolylineLoopControlPoints(node, sourcePoint, targetPoint, dist); // 3. 如果定义了连接桩,调整端点以与连接桩边界相交 | If the port is defined, adjust the endpoint to intersect with the port boundary if (sourcePort) sourcePoint = getPortConnectionPoint(sourcePort, controlPoints[0]); if (targetPort) targetPoint = getPortConnectionPoint(targetPort, controlPoints.at(-1) as Point); return getPolylinePath([sourcePoint, ...controlPoints, targetPoint], radius); } /** * 获取环形折线边的控制点 * * Get the control points of the loop polyline edge * @param node - 节点实例 | Node instance * @param sourcePoint - 起点 | Source point * @param targetPoint - 终点 | Target point * @param dist - 从节点 keyShape 边缘到自环顶部的距离 | The distance from the edge of the node keyShape to the top of the self-loop * @returns 控制点 | Control points */ export function getPolylineLoopControlPoints(node: Node, sourcePoint: Point, targetPoint: Point, dist: number) { const controlPoints: Point[] = []; const bbox = getNodeBBox(node); // 1. 起点和终点相同 | The start and end points are the same if (isEqual(sourcePoint, targetPoint)) { const side = getNearestBoundarySide(sourcePoint, bbox); switch (side) { case 'left': controlPoints.push([sourcePoint[0] - dist, sourcePoint[1]]); controlPoints.push([sourcePoint[0] - dist, sourcePoint[1] + dist]); controlPoints.push([sourcePoint[0], sourcePoint[1] + dist]); break; case 'right': controlPoints.push([sourcePoint[0] + dist, sourcePoint[1]]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1] + dist]); controlPoints.push([sourcePoint[0], sourcePoint[1] + dist]); break; case 'top': controlPoints.push([sourcePoint[0], sourcePoint[1] - dist]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1] - dist]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1]]); break; case 'bottom': controlPoints.push([sourcePoint[0], sourcePoint[1] + dist]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1] + dist]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1]]); break; } } else { const sourceSide = getNearestBoundarySide(sourcePoint, bbox); const targetSide = getNearestBoundarySide(targetPoint, bbox); // 2. 起点与终点同边 | The start and end points are on the same side if (sourceSide === targetSide) { const side = sourceSide; let x, y; switch (side) { case 'left': x = Math.min(sourcePoint[0], targetPoint[0]) - dist; controlPoints.push([x, sourcePoint[1]]); controlPoints.push([x, targetPoint[1]]); break; case 'right': x = Math.max(sourcePoint[0], targetPoint[0]) + dist; controlPoints.push([x, sourcePoint[1]]); controlPoints.push([x, targetPoint[1]]); break; case 'top': y = Math.min(sourcePoint[1], targetPoint[1]) - dist; controlPoints.push([sourcePoint[0], y]); controlPoints.push([targetPoint[0], y]); break; case 'bottom': y = Math.max(sourcePoint[1], targetPoint[1]) + dist; controlPoints.push([sourcePoint[0], y]); controlPoints.push([targetPoint[0], y]); break; } } else { // 3. 起点与终点不同边 | The start and end points are on different sides const getPointOffSide = (side: 'left' | 'right' | 'top' | 'bottom', point: Point): Point => { return { left: [point[0] - dist, point[1]], right: [point[0] + dist, point[1]], top: [point[0], point[1] - dist], bottom: [point[0], point[1] + dist], }[side] as Point; }; const p1 = getPointOffSide(sourceSide, sourcePoint); const p2 = getPointOffSide(targetSide, targetPoint); const p3 = freeJoin(p1, p2, bbox); controlPoints.push(p1, p3, p2); } } return controlPoints; } /** * 获取子图内的所有边,并按照内部边和外部边分组 * * Get all the edges in the subgraph and group them into internal and external edges * @param ids - 节点 ID 数组 | Node ID array * @param getRelatedEdges - 获取节点邻边 | Get node edges * @returns 子图边 | Subgraph edges */ export function getSubgraphRelatedEdges(ids: ID[], getRelatedEdges: (id: ID) => EdgeData[]) { const edges = new Set(); const internal = new Set(); const external = new Set(); ids.forEach((id) => { const relatedEdges = getRelatedEdges(id); relatedEdges.forEach((edge) => { edges.add(edge); if (ids.includes(edge.source) && ids.includes(edge.target)) internal.add(edge); else external.add(edge); }); }); return { edges: Array.from(edges), internal: Array.from(internal), external: Array.from(external) }; } /** * 获取边的实际连接节点 * * Get the actual connected object of the edge * @param node - 逻辑连接节点数据 | Logical connection node data * @param getParentData - 获取父节点数据 | Get parent node data * @returns 实际连接节点数据 | Actual connected node data */ export function findActualConnectNodeData(node: NodeLikeData, getParentData: (id: ID) => NodeLikeData | undefined) { const path: NodeLikeData[] = []; let current = node; while (current) { path.push(current); const parent = getParentData(idOf(current)); if (parent) current = parent; else break; } if (path.some((n) => n.style?.collapsed)) { const index = path.reverse().findIndex(isCollapsed); return path[index] || path.at(-1); } return node; } /** * 获取箭头大小,若用户未指定,则根据线宽自动计算 * * Get the size of the arrow * @param lineWidth - 箭头所在边的线宽 | The line width of the edge where the arrow is located * @param size - 自定义箭头大小 | Custom arrow size * @returns 箭头大小 | Arrow size */ export function getArrowSize(lineWidth: number, size?: Size): Size { if (size) return size; if (lineWidth < 4) return 10; if (lineWidth === 4) return 12; return lineWidth * 2.5; }