diff --git a/__tests__/registry/connector/jumpover.spec.ts b/__tests__/registry/connector/jumpover.spec.ts index 80b044601ec..717114deb21 100644 --- a/__tests__/registry/connector/jumpover.spec.ts +++ b/__tests__/registry/connector/jumpover.spec.ts @@ -378,4 +378,177 @@ describe('jumpover connector', () => { expect(typeof result).toBe('string') }) }) + + describe('jumpDirection option', () => { + // Helper: set up two crossing edges. + // otherCell is placed BEFORE mockCell in the edges array so it passes + // the idx <= thisIndex filter and participates in intersection detection. + function setupCrossingEdges( + thisSource: Point, + thisTarget: Point, + otherSource: Point, + otherTarget: Point, + ) { + const otherCell = { getConnector: vi.fn(() => ({ name: 'jumpover' })) } + const otherView = { + sourcePoint: otherSource, + targetPoint: otherTarget, + routePoints: [], + } + + // otherCell at index 0, mockCell at index 1 => thisIndex=1, otherCell is always included + mockGraph.model.getEdges.mockReturnValue([otherCell, mockCell]) + mockGraph.findViewByCell.mockImplementation((cell: any) => + cell === mockCell ? mockView : otherView, + ) + + mockView.sourcePoint = thisSource + mockView.targetPoint = thisTarget + mockView.routePoints = [] + + return { otherCell, otherView } + } + + describe('jumpDirection: "horizontal"', () => { + it('should create jump arc on a horizontal line crossing a vertical line', () => { + setupCrossingEdges( + new Point(0, 50), + new Point(100, 50), // this edge: horizontal + new Point(50, 0), + new Point(50, 100), // other edge: vertical + ) + + const result = jumpover.call( + mockView, + new Point(0, 50), + new Point(100, 50), + [], + { jumpDirection: 'horizontal' }, + ) as string + + // arc jump produces 'C' (cubic bezier) segments in the serialized path + expect(result).toContain('C') + }) + + it('should NOT create jump arc on a vertical line crossing a horizontal line', () => { + setupCrossingEdges( + new Point(50, 0), + new Point(50, 100), // this edge: vertical + new Point(0, 50), + new Point(100, 50), // other edge: horizontal + ) + + const result = jumpover.call( + mockView, + new Point(50, 0), + new Point(50, 100), + [], + { jumpDirection: 'horizontal' }, + ) as string + + // vertical line should pass through without a jump arc + expect(result).not.toContain('C') + }) + }) + + describe('jumpDirection: "vertical"', () => { + it('should create jump arc on a vertical line crossing a horizontal line', () => { + setupCrossingEdges( + new Point(50, 0), + new Point(50, 100), // this edge: vertical + new Point(0, 50), + new Point(100, 50), // other edge: horizontal + ) + + const result = jumpover.call( + mockView, + new Point(50, 0), + new Point(50, 100), + [], + { jumpDirection: 'vertical' }, + ) as string + + expect(result).toContain('C') + }) + + it('should NOT create jump arc on a horizontal line crossing a vertical line', () => { + setupCrossingEdges( + new Point(0, 50), + new Point(100, 50), // this edge: horizontal + new Point(50, 0), + new Point(50, 100), // other edge: vertical + ) + + const result = jumpover.call( + mockView, + new Point(0, 50), + new Point(100, 50), + [], + { jumpDirection: 'vertical' }, + ) as string + + // horizontal line should pass through without a jump arc + expect(result).not.toContain('C') + }) + }) + + describe('jumpDirection: "both" (default)', () => { + it('should create jump arc on a horizontal line when jumpDirection is "both"', () => { + setupCrossingEdges( + new Point(0, 50), + new Point(100, 50), // this edge: horizontal + new Point(50, 0), + new Point(50, 100), // other edge: vertical + ) + + const result = jumpover.call( + mockView, + new Point(0, 50), + new Point(100, 50), + [], + { jumpDirection: 'both' }, + ) as string + + expect(result).toContain('C') + }) + + it('should create jump arc on a vertical line when jumpDirection is "both"', () => { + setupCrossingEdges( + new Point(50, 0), + new Point(50, 100), // this edge: vertical + new Point(0, 50), + new Point(100, 50), // other edge: horizontal + ) + + const result = jumpover.call( + mockView, + new Point(50, 0), + new Point(50, 100), + [], + { jumpDirection: 'both' }, + ) as string + + expect(result).toContain('C') + }) + + it('should default to "both" behavior when jumpDirection is omitted', () => { + setupCrossingEdges( + new Point(0, 50), + new Point(100, 50), // this edge: horizontal + new Point(50, 0), + new Point(50, 100), // other edge: vertical + ) + + const result = jumpover.call( + mockView, + new Point(0, 50), + new Point(100, 50), + [], + {}, // no jumpDirection + ) as string + + expect(result).toContain('C') + }) + }) + }) }) diff --git a/examples/src/App.tsx b/examples/src/App.tsx index 6dc21858060..66e4b4e7e30 100644 --- a/examples/src/App.tsx +++ b/examples/src/App.tsx @@ -16,6 +16,7 @@ import { CaseElkExample } from './pages/case/elk' import { CaseErExample } from './pages/case/er' import { CaseMindExample } from './pages/case/mind' import { CaseSwimlaneExample } from './pages/case/swimlane' +import { JumpoverDirectionExample } from './pages/connector/jumpover-direction' import { OffsetRoundedExample } from './pages/connector/offset-rounded' import { XmindCurveExample } from './pages/connector/xmind-curve' import { EdgeExample } from './pages/edge' @@ -93,6 +94,10 @@ function App() { element={} /> } /> + } + /> } /> } /> } /> diff --git a/examples/src/pages/connector/jumpover-direction.tsx b/examples/src/pages/connector/jumpover-direction.tsx new file mode 100644 index 00000000000..bb29caa76bb --- /dev/null +++ b/examples/src/pages/connector/jumpover-direction.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Graph } from '@antv/x6' +import '../index.less' + +type JumpDirection = 'both' | 'horizontal' | 'vertical' + +export const JumpoverDirectionExample: React.FC = () => { + const containerRef = useRef(null) + const graphRef = useRef(null) + const [direction, setDirection] = useState('both') + + useEffect(() => { + if (!containerRef.current) return + + const graph = new Graph({ + container: containerRef.current, + grid: true, + width: 800, + height: 500, + }) + graphRef.current = graph + + // 四个节点,使水平线和垂直线互相交叉 + const a = graph.addNode({ + x: 50, + y: 220, + width: 80, + height: 40, + label: 'A', + }) + const b = graph.addNode({ + x: 680, + y: 220, + width: 80, + height: 40, + label: 'B', + }) + const c = graph.addNode({ + x: 380, + y: 50, + width: 80, + height: 40, + label: 'C', + }) + const d = graph.addNode({ + x: 380, + y: 410, + width: 80, + height: 40, + label: 'D', + }) + + // 水平线:A → B(穿越垂直线) + graph.addEdge({ + source: a, + target: b, + connector: { name: 'jumpover', args: { jumpDirection: direction } }, + attrs: { line: { stroke: '#1890ff', strokeWidth: 2 } }, + labels: [{ attrs: { label: { text: '水平线 A→B', fill: '#1890ff' } } }], + }) + + // 垂直线:C → D(穿越水平线) + graph.addEdge({ + source: c, + target: d, + connector: { name: 'jumpover', args: { jumpDirection: direction } }, + attrs: { line: { stroke: '#f5222d', strokeWidth: 2 } }, + labels: [{ attrs: { label: { text: '垂直线 C→D', fill: '#f5222d' } } }], + }) + + return () => { + graph.dispose() + graphRef.current = null + } + // 每次 direction 变化时重建画布 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [direction]) + + return ( +
+

jumpover — jumpDirection 演示

+
+ jumpDirection: + {(['both', 'horizontal', 'vertical'] as JumpDirection[]).map((v) => ( + + ))} +
+
+ {direction === 'both' && + '两条线交叉时,先绘制的那条(水平/垂直均可)会产生跳弧。'} + {direction === 'horizontal' && + '只有水平线(A→B,蓝色)在交叉处产生跳弧;垂直线(C→D,红色)直线穿过。'} + {direction === 'vertical' && + '只有垂直线(C→D,红色)在交叉处产生跳弧;水平线(A→B,蓝色)直线穿过。'} +
+
+
+ ) +} + +export default JumpoverDirectionExample diff --git a/examples/src/pages/index.tsx b/examples/src/pages/index.tsx index 345aad7bcf9..908f7b357bf 100755 --- a/examples/src/pages/index.tsx +++ b/examples/src/pages/index.tsx @@ -103,6 +103,10 @@ const dataSource = [ example: 'connector/xmind-curve', description: '脑图连接器', }, + { + example: 'connector/jumpover-direction', + description: 'jumpover jumpDirection 演示', + }, // ========= tools ========= { example: 'tools/clean', diff --git a/src/registry/connector/jumpover.ts b/src/registry/connector/jumpover.ts index ee8957cff13..86ed706f3a3 100644 --- a/src/registry/connector/jumpover.ts +++ b/src/registry/connector/jumpover.ts @@ -295,6 +295,7 @@ export interface JumpoverConnectorOptions extends ConnectorBaseOptions { radius?: number type?: JumpType ignoreConnectors?: string[] + jumpDirection?: 'both' | 'horizontal' | 'vertical' } export const jumpover: ConnectorDefinition = @@ -324,6 +325,7 @@ export const jumpover: ConnectorDefinition = ) } + const jumpDirection = options.jumpDirection || 'both' const edge = this.cell const thisIndex = allLinks.indexOf(edge) const defaultConnector = graph.options.connecting.connector || {} @@ -336,10 +338,23 @@ export const jumpover: ConnectorDefinition = if (ignoreConnectors.includes(connector.name)) { return false } - // filter out links that are above this one and have the same connector type - // otherwise there would double hoops for each intersection - if (idx > thisIndex) { - return connector.name !== 'jumpover' + // Deduplicate jumpover connectors to prevent double arcs. + if (connector.name === 'jumpover') { + const otherJumpDirection = connector.args?.jumpDirection || 'both' + // Only tie-break if both edges are in 'both' mode. For specific directions, + // the orientation-based filtering in the loop below naturally prevents + // double arcs for orthogonal lines. + if ( + jumpDirection === 'both' && + otherJumpDirection === 'both' && + idx > thisIndex + ) { + return false + } + // If directions are different specific values, prefer horizontal to avoid double arcs. + if (jumpDirection === 'vertical' && otherJumpDirection === 'horizontal') { + return false + } } return true }) @@ -374,7 +389,17 @@ export const jumpover: ConnectorDefinition = thisLines.forEach((line) => { // iterate all links and grab the intersections with this line // these are then sorted by distance so the line can be split more easily - + // Filter lines based on jumpDirection + const shouldJump = + jumpDirection === 'both' || + (jumpDirection === 'horizontal' + ? Math.abs(line.end.y - line.start.y) < 1 + : Math.abs(line.end.x - line.start.x) < 1) + + if (!shouldJump) { + jumpingLines.push(line) + return + } const intersections = edges .reduce((memo, link, i) => { // don't intersection with itself