From b57dee977f6f7ba2b6692ab8e2e9a40d571c6940 Mon Sep 17 00:00:00 2001 From: zbynekstara Date: Thu, 20 Aug 2026 16:50:43 +0200 Subject: [PATCH 1/6] refactor(alg.rightAnglePath): consolidate duplicate code in helpers --- .../joint-core/src/alg/rightAnglePath.mjs | 1135 ++++++++--------- 1 file changed, 529 insertions(+), 606 deletions(-) diff --git a/packages/joint-core/src/alg/rightAnglePath.mjs b/packages/joint-core/src/alg/rightAnglePath.mjs index e63757cb60..95321759cb 100644 --- a/packages/joint-core/src/alg/rightAnglePath.mjs +++ b/packages/joint-core/src/alg/rightAnglePath.mjs @@ -134,6 +134,423 @@ function getVerticalDistance(source, target) { return [topD, bottomD]; } +// Builds the S-shaped connection for the sides facing each other on the horizontal axis +// (`left => right` and `right => left`). +// `isSourcePastTargetMargin` tells whether the source offset point already lies beyond the +// boundary the target route leaves through - if so, both ends share a single vertical segment. +function getHorizontalSShapePoints(sourcePoint, targetPoint, isSourcePastTargetMargin) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + const middleY = (sOffsetY + tOffsetY) / 2; + + if (isSourcePastTargetMargin) { + const middleX = (sOffsetX + tOffsetX) / 2; + return [ + { x: middleX, y: sOffsetY }, + { x: middleX, y: middleY }, + { x: middleX, y: middleY }, + { x: middleX, y: tOffsetY } + ]; + } + + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX, y: middleY }, + { x: tOffsetX, y: middleY }, + { x: tOffsetX, y: tOffsetY } + ]; +} + +// Builds the S-shaped connection for the sides facing each other on the vertical axis +// (`top => bottom` and `bottom => top`) - the counterpart of `getHorizontalSShapePoints()`. +// `isSourcePastTargetMargin` tells whether the source offset point already lies beyond the +// boundary the target route leaves through - if so, both ends share a single horizontal segment. +function getVerticalSShapePoints(sourcePoint, targetPoint, isSourcePastTargetMargin) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + const middleX = (sOffsetX + tOffsetX) / 2; + + if (isSourcePastTargetMargin) { + const middleY = (sOffsetY + tOffsetY) / 2; + return [ + { x: sOffsetX, y: middleY }, + { x: middleX, y: middleY }, + { x: middleX, y: middleY }, + { x: tOffsetX, y: middleY } + ]; + } + + return [ + { x: sOffsetX, y: sOffsetY }, + { x: middleX, y: sOffsetY }, + { x: middleX, y: tOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; +} + +// Assembles the points of the route between the sides lying on the horizontal axis +// (`left => right` and `right => left`). +// `bends` is the pair of vertical segments the route bends at (`x1` and `x2`), joined by a +// horizontal one at `y`, with `isUpwardsShorter` telling whether the shorter way around +// the elements is the one upwards. Without it, the two offset points are joined by a +// single vertical segment halfway between them. +function getHorizontalRoutePoints(sourceBBox, targetBBox, sourcePoint, targetPoint, bends = null) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + + if (!bends) { + const x = (sOffsetX + tOffsetX) / 2; + return [ + { x, y: sOffsetY }, + { x, y: tOffsetY } + ]; + } + + const { y, isUpwardsShorter } = bends; + let { x1, x2 } = bends; + + const sBoxY0 = sourceBBox.y; + const sBoxY1 = sBoxY0 + sourceBBox.height; + const tBoxY0 = targetBBox.y; + const tBoxY1 = tBoxY0 + targetBBox.height; + + // This is an edge case when the source and target intersect and + if ((isUpwardsShorter && sOffsetY < tBoxY0) || (!isUpwardsShorter && sOffsetY > tBoxY1)) { + // the path should no longer rely on the target boundary in `x1` + x1 = sOffsetX; + } else if ((isUpwardsShorter && tOffsetY < sBoxY0) || (!isUpwardsShorter && tOffsetY > sBoxY1)) { + // the path should no longer rely on the source boundary in `x2` + x2 = tOffsetX; + } + + return [ + { x: x1, y: sOffsetY }, + { x: x1, y }, + { x: x2, y }, + { x: x2, y: tOffsetY } + ]; +} + +// Assembles the points of the route between the sides lying on the vertical axis +// (`top => bottom` and `bottom => top`) - the counterpart of `getHorizontalRoutePoints()`. +// `bends` is the pair of horizontal segments the route bends at (`y1` and `y2`), joined by +// a vertical one at `x`, with `isLeftShorter` telling whether the shorter way around the +// elements is the one to the left. Without it, the two offset points are joined by a +// single horizontal segment halfway between them. +function getVerticalRoutePoints(sourceBBox, targetBBox, sourcePoint, targetPoint, bends = null) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + + if (!bends) { + const y = (sOffsetY + tOffsetY) / 2; + return [ + { x: sOffsetX, y }, + { x: tOffsetX, y } + ]; + } + + const { x, isLeftShorter } = bends; + let { y1, y2 } = bends; + + const sBoxX0 = sourceBBox.x; + const sBoxX1 = sBoxX0 + sourceBBox.width; + const tBoxX0 = targetBBox.x; + const tBoxX1 = tBoxX0 + targetBBox.width; + + // This is an edge case when the source and target intersect and + if ((isLeftShorter && sOffsetX < tBoxX0) || (!isLeftShorter && sOffsetX > tBoxX1)) { + // the path should no longer rely on the target boundary in `y1` + y1 = sOffsetY; + } else if ((isLeftShorter && tOffsetX < sBoxX0) || (!isLeftShorter && tOffsetX > sBoxX1)) { + // the path should no longer rely on the source boundary in `y2` + y2 = tOffsetY; + } + + return [ + { x: sOffsetX, y: y1 }, + { x, y: y1 }, + { x, y: y2 }, + { x: tOffsetX, y: y2 } + ]; +} + +// Assembles the points of the route between the sides facing the same way on the horizontal +// axis (`left => left` and `right => right`). The route leaves both sides and joins them with +// a horizontal segment at `y`, reaching it along `x2` on the source end and `x1` on the target +// one. +// `isSourceFurtherOut` tells whether it is the source side that lies further out - the route +// then has to clear the source element, otherwise the target one - and `isUpwardsShorter` +// whether clearing it above the elements is shorter than below. +function getHorizontalSameSideRoutePoints(source, target, sourcePoint, targetPoint, bends) { + const { y: sOffsetY } = sourcePoint; + const { y: tOffsetY } = targetPoint; + const { x1, x2, isSourceFurtherOut, isUpwardsShorter } = bends; + + // The route has to clear the element lying further out and reach the offset point of the other one. + const { bbox, margin } = isSourceFurtherOut ? source : target; + const offsetY = isSourceFurtherOut ? tOffsetY : sOffsetY; + + const y = isUpwardsShorter + ? Math.min(bbox.y - margin, offsetY) + : Math.max(bbox.y + bbox.height + margin, offsetY); + + return [ + { x: x2, y: sOffsetY }, + { x: x2, y }, + { x: x1, y }, + { x: x1, y: tOffsetY } + ]; +} + +// Assembles the points of the route between the sides facing the same way on the vertical +// axis (`top => top` and `bottom => bottom`) - the transposed counterpart of +// `getHorizontalSameSideRoutePoints()`. The route leaves both sides and joins them with a +// vertical segment at `x`, reaching it along `y2` on the source end and `y1` on the target one. +// `isSourceFurtherOut` tells whether it is the source side that lies further out - the route +// then has to clear the source element, otherwise the target one - and `isLeftShorter` +// whether clearing it to the left of the elements is shorter than to the right. +function getVerticalSameSideRoutePoints(source, target, sourcePoint, targetPoint, bends) { + const { x: sOffsetX } = sourcePoint; + const { x: tOffsetX } = targetPoint; + const { y1, y2, isSourceFurtherOut, isLeftShorter } = bends; + + // The route has to clear the element lying further out and reach the offset point of the other one. + const { bbox, margin } = isSourceFurtherOut ? source : target; + const offsetX = isSourceFurtherOut ? tOffsetX : sOffsetX; + + const x = isLeftShorter + ? Math.min(bbox.x - margin, offsetX) + : Math.max(bbox.x + bbox.width + margin, offsetX); + + return [ + { x: sOffsetX, y: y2 }, + { x, y: y2 }, + { x, y: y1 }, + { x: tOffsetX, y: y1 } + ]; +} + +// Assembles the points of the route from the `left` or `right` source side to the `bottom` +// target side, when the target lies beyond that source side (`left => bottom` and +// `right => bottom`). +// `x` is the vertical segment the route bends at, `canTurnAtSource` tells whether the route +// may turn towards the target right after leaving the source side instead. +function getSideToBottomPoints(targetBBox, sourcePoint, targetPoint, sourceMargin, x, canTurnAtSource) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + + // The source offset point already lies below the target offset point. + if (sOffsetY >= tOffsetY) { + return [{ x: tOffsetX, y: sOffsetY }]; + } + + if (canTurnAtSource) { + const tBoxY1 = targetBBox.y + targetBBox.height; + + // The turn would end up below the bottom side of the target element, + // so head straight for the target instead. + if (sOffsetY + sourceMargin > tBoxY1) { + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX, y: tOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX, y: sOffsetY + sourceMargin }, + { x, y: sOffsetY + sourceMargin }, + { x, y: tOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x, y: sOffsetY }, + { x, y: tOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; +} + +// Assembles the points of the route from the `left` or `right` source side to the `top` +// target side, when the target lies beyond that source side (`left => top` and +// `right => top`) - the counterpart of `getSideToBottomPoints()`. +function getSideToTopPoints(targetBBox, sourcePoint, targetPoint, sourceMargin, x, canTurnAtSource) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + + // The source offset point already lies above the target offset point. + if (sOffsetY <= tOffsetY) { + return [{ x: tOffsetX, y: sOffsetY }]; + } + + if (canTurnAtSource) { + const tBoxY0 = targetBBox.y; + + // The turn would end up above the top side of the target element, + // so head straight for the target instead. + if (sOffsetY - sourceMargin < tBoxY0) { + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX, y: tOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX, y: sOffsetY - sourceMargin }, + { x, y: sOffsetY - sourceMargin }, + { x, y: tOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x, y: sOffsetY }, + { x, y: tOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; +} + +// Assembles the points of the route from the `top` or `bottom` source side to the `left` +// target side, when the target lies beyond that source side (`top => left` and +// `bottom => left`) - the transposed counterpart of `getSideToBottomPoints()`. +// `y` is the horizontal segment the route bends at, `canTurnAtSource` tells whether the route +// may turn towards the target right after leaving the source side instead. +function getSideToLeftPoints(targetBBox, sourcePoint, targetPoint, sourceMargin, y, canTurnAtSource) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + + // The source offset point already lies to the left of the target offset point. + if (sOffsetX <= tOffsetX) { + return [{ x: sOffsetX, y: tOffsetY }]; + } + + if (canTurnAtSource) { + const tBoxX0 = targetBBox.x; + + // The turn would end up beyond the left side of the target element, + // so head straight for the target instead. + if (sOffsetX - sourceMargin < tBoxX0) { + return [ + { x: sOffsetX, y: sOffsetY }, + { x: tOffsetX, y: sOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX - sourceMargin, y: sOffsetY }, + { x: sOffsetX - sourceMargin, y }, + { x: tOffsetX, y }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x: sOffsetX, y }, + { x: tOffsetX, y }, + { x: tOffsetX, y: tOffsetY } + ]; +} + +// Assembles the points of the route from the `top` or `bottom` source side to the `right` +// target side, when the target lies beyond that source side (`top => right` and +// `bottom => right`) - the mirror of `getSideToLeftPoints()`. +function getSideToRightPoints(targetBBox, sourcePoint, targetPoint, sourceMargin, y, canTurnAtSource) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + + // The source offset point already lies to the right of the target offset point. + if (sOffsetX >= tOffsetX) { + return [{ x: sOffsetX, y: tOffsetY }]; + } + + if (canTurnAtSource) { + const tBoxX1 = targetBBox.x + targetBBox.width; + + // The turn would end up beyond the right side of the target element, + // so head straight for the target instead. + if (sOffsetX + sourceMargin > tBoxX1) { + return [ + { x: sOffsetX, y: sOffsetY }, + { x: tOffsetX, y: sOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX + sourceMargin, y: sOffsetY }, + { x: sOffsetX + sourceMargin, y }, + { x: tOffsetX, y }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x: sOffsetX, y }, + { x: tOffsetX, y }, + { x: tOffsetX, y: tOffsetY } + ]; +} + +// Assembles the points of the route from the `left` or `right` source side to the `top` or +// `bottom` target side, when the target does not lie beyond that source side. The route +// leaves the source side and bends at `y`. +// `needsTargetApproach` tells whether it then has to come at the target sideways - through +// `approachX` - instead of reaching it right at the bend. +function getSideToTopOrBottomPoints(sourcePoint, targetPoint, y, approachX, needsTargetApproach) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + + if (needsTargetApproach) { + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX, y }, + { x: approachX, y }, + { x: approachX, y: tOffsetY }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX, y }, + { x: tOffsetX, y } + ]; +} + +// Assembles the points of the route from the `top` or `bottom` source side to the `left` or +// `right` target side, when the target does not lie beyond that source side - the transposed +// counterpart of `getSideToTopOrBottomPoints()`. The route leaves the source side and bends +// at `x`. +// `needsTargetApproach` tells whether it then has to come at the target sideways - through +// `approachY` - instead of reaching it right at the bend. +function getSideToLeftOrRightPoints(sourcePoint, targetPoint, x, approachY, needsTargetApproach) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + + if (needsTargetApproach) { + return [ + { x: sOffsetX, y: sOffsetY }, + { x, y: sOffsetY }, + { x, y: approachY }, + { x: tOffsetX, y: approachY }, + { x: tOffsetX, y: tOffsetY } + ]; + } + + return [ + { x: sOffsetX, y: sOffsetY }, + { x, y: sOffsetY }, + { x, y: tOffsetY } + ]; +} + export function rightAnglePath(source, target, opt = {}) { const { bbox: sourceBBox, @@ -230,24 +647,7 @@ export function rightAnglePath(source, target, opt = {}) { // Use S-shaped connection if (isPointInsideSource || isPointInsideTarget) { - const middleY = (sOffsetY + tOffsetY) / 2; - - if (sOffsetX < tMinMarginX1) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: middleY }, - { x: tOffsetX, y: middleY }, - { x: tOffsetX, y: tOffsetY } - ]; - } else { - const middleX = (sOffsetX + tOffsetX) / 2; - return [ - { x: middleX, y: sOffsetY }, - { x: middleX, y: middleY }, - { x: middleX, y: middleY }, - { x: middleX, y: tOffsetY } - ]; - } + return getHorizontalSShapePoints(sourceOffsetPoint, targetOffsetPoint, sOffsetX >= tMinMarginX1); } if (sOffsetX < tOffsetX) { @@ -281,54 +681,24 @@ export function rightAnglePath(source, target, opt = {}) { // the case when the source is to the left of the target element. x1 = Math.min(sOffsetX, tBoxX0 - targetMargin); x2 = Math.max(tOffsetX, sBoxX1 + sourceMargin); - - // This is an edge case when the source and target intersect and - if ((isUpwardsShorter && sOffsetY < tBoxY0) || (!isUpwardsShorter && sOffsetY > tBoxY1)) { - // the path should no longer rely on minimal x boundary in `x1` - x1 = sOffsetX; - } else if ((isUpwardsShorter && tOffsetY < sBoxY0) || (!isUpwardsShorter && tOffsetY > sBoxY1)) { - // the path should no longer rely on maximal x boundary in `x2` - x2 = tOffsetX; - } } - return [ - { x: x1, y: sOffsetY }, - { x: x1, y }, - { x: x2, y }, - { x: x2, y: tOffsetY } - ]; + return getHorizontalRoutePoints(sourceBBox, targetBBox, sourceOffsetPoint, targetOffsetPoint, { + x1, + x2, + y, + isUpwardsShorter + }); } - const x = (sOffsetX + tOffsetX) / 2; - return [ - { x, y: sOffsetY }, - { x, y: tOffsetY }, - ]; + return getHorizontalRoutePoints(sourceBBox, targetBBox, sourceOffsetPoint, targetOffsetPoint); } else if (sourceSide === 'right' && targetSide === 'left') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetOffsetPoint); const isPointInsideTarget = inflatedTargetBBox.containsPoint(sourceOffsetPoint); // Use S-shaped connection if (isPointInsideSource || isPointInsideTarget) { - const middleY = (sOffsetY + tOffsetY) / 2; - - if (sOffsetX > tMinMarginX0) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: middleY }, - { x: tOffsetX, y: middleY }, - { x: tOffsetX, y: tOffsetY } - ]; - } else { - const middleX = (sOffsetX + tOffsetX) / 2; - return [ - { x: middleX, y: sOffsetY }, - { x: middleX, y: middleY }, - { x: middleX, y: middleY }, - { x: middleX, y: tOffsetY } - ]; - } + return getHorizontalSShapePoints(sourceOffsetPoint, targetOffsetPoint, sOffsetX <= tMinMarginX0); } if (sOffsetX > tOffsetX) { @@ -361,54 +731,24 @@ export function rightAnglePath(source, target, opt = {}) { // the case when the source is to the left of the target element. x1 = Math.max(sOffsetX, tBoxX1 + targetMargin); x2 = Math.min(tOffsetX, sBoxX0 - sourceMargin); - - // This is an edge case when the source and target intersect and - if ((isUpwardsShorter && sOffsetY < tBoxY0) || (!isUpwardsShorter && sOffsetY > tBoxY1)) { - // the path should no longer rely on maximal x boundary in `x1` - x1 = sOffsetX; - } else if ((isUpwardsShorter && tOffsetY < sBoxY0) || (!isUpwardsShorter && tOffsetY > sBoxY1)) { - // the path should no longer rely on minimal x boundary in `x2` - x2 = tOffsetX; - } } - return [ - { x: x1, y: sOffsetY }, - { x: x1, y }, - { x: x2, y }, - { x: x2, y: tOffsetY } - ]; + return getHorizontalRoutePoints(sourceBBox, targetBBox, sourceOffsetPoint, targetOffsetPoint, { + x1, + x2, + y, + isUpwardsShorter + }); } - const x = (sOffsetX + tOffsetX) / 2; - return [ - { x, y: sOffsetY }, - { x, y: tOffsetY } - ]; + return getHorizontalRoutePoints(sourceBBox, targetBBox, sourceOffsetPoint, targetOffsetPoint); } else if (sourceSide === 'top' && targetSide === 'bottom') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetOffsetPoint); const isPointInsideTarget = inflatedTargetBBox.containsPoint(sourceOffsetPoint); // Use S-shaped connection if (isPointInsideSource || isPointInsideTarget) { - const middleX = (sOffsetX + tOffsetX) / 2; - - if (sOffsetY < tMinMarginY1) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x: middleX, y: sOffsetY }, - { x: middleX, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } else { - const middleY = (sOffsetY + tOffsetY) / 2; - return [ - { x: sOffsetX, y: middleY }, - { x: middleX, y: middleY }, - { x: middleX, y: middleY }, - { x: tOffsetX, y: middleY } - ]; - } + return getVerticalSShapePoints(sourceOffsetPoint, targetOffsetPoint, sOffsetY >= tMinMarginY1); } if (sMarginY0 < tOffsetY) { @@ -441,54 +781,24 @@ export function rightAnglePath(source, target, opt = {}) { // the case when the source is to the left of the target element. y1 = Math.min(sOffsetY, tBoxY0 - targetMargin); y2 = Math.max(tOffsetY, sBoxY1 + sourceMargin); - - // This is an edge case when the source and target intersect and - if ((isLeftShorter && sOffsetX < tBoxX0) || (!isLeftShorter && sOffsetX > tBoxX1)) { - // the path should no longer rely on minimal y boundary in `y1` - y1 = sOffsetY; - } else if ((isLeftShorter && tOffsetX < sBoxX0) || (!isLeftShorter && tOffsetX > sBoxX1)) { - // the path should no longer rely on maximal y boundary in `y2` - y2 = tOffsetY; - } } - return [ - { x: sOffsetX, y: y1 }, - { x, y: y1 }, - { x, y: y2 }, - { x: tOffsetX, y: y2 } - ]; + return getVerticalRoutePoints(sourceBBox, targetBBox, sourceOffsetPoint, targetOffsetPoint, { + y1, + y2, + x, + isLeftShorter + }); } - const y = (sOffsetY + tOffsetY) / 2; - return [ - { x: sOffsetX, y }, - { x: tOffsetX, y } - ]; + return getVerticalRoutePoints(sourceBBox, targetBBox, sourceOffsetPoint, targetOffsetPoint); } else if (sourceSide === 'bottom' && targetSide === 'top') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetOffsetPoint); const isPointInsideTarget = inflatedTargetBBox.containsPoint(sourceOffsetPoint); // Use S-shaped connection if (isPointInsideSource || isPointInsideTarget) { - const middleX = (sOffsetX + tOffsetX) / 2; - - if (sOffsetY > tMinMarginY0) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x: middleX, y: sOffsetY }, - { x: middleX, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } else { - const middleY = (sOffsetY + tOffsetY) / 2; - return [ - { x: sOffsetX, y: middleY }, - { x: middleX, y: middleY }, - { x: middleX, y: middleY }, - { x: tOffsetX, y: middleY } - ]; - } + return getVerticalSShapePoints(sourceOffsetPoint, targetOffsetPoint, sOffsetY <= tMinMarginY0); } if (sMarginY1 > tOffsetY) { @@ -521,30 +831,17 @@ export function rightAnglePath(source, target, opt = {}) { // the case when the source is to the left of the target element. y1 = Math.max(sOffsetY, tBoxY1 + targetMargin); y2 = Math.min(tOffsetY, sBoxY0 - sourceMargin); - - // This is an edge case when the source and target intersect and - if ((isLeftShorter && sOffsetX < tBoxX0) || (!isLeftShorter && sOffsetX > tBoxX1)) { - // the path should no longer rely on maximal y boundary in `y1` - y1 = sOffsetY; - } else if ((isLeftShorter && tOffsetX < sBoxX0) || (!isLeftShorter && tOffsetX > sBoxX1)) { - // the path should no longer rely on minimal y boundary in `y2` - y2 = tOffsetY; - } } - return [ - { x: sOffsetX, y: y1 }, - { x, y: y1 }, - { x, y: y2 }, - { x: tOffsetX, y: y2 } - ]; + return getVerticalRoutePoints(sourceBBox, targetBBox, sourceOffsetPoint, targetOffsetPoint, { + y1, + y2, + x, + isLeftShorter + }); } - const y = (sOffsetY + tOffsetY) / 2; - return [ - { x: sOffsetX, y }, - { x: tOffsetX, y } - ]; + return getVerticalRoutePoints(sourceBBox, targetBBox, sourceOffsetPoint, targetOffsetPoint); } else if (sourceSide === 'top' && targetSide === 'top') { const useUShapeConnection = targetInSourceBBox || @@ -560,31 +857,16 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - let x; const y1 = Math.min((sBoxY1 + tBoxY0) / 2, tOffsetY); const y2 = Math.min((sBoxY0 + tBoxY1) / 2, sOffsetY); - if (tOffsetY < sOffsetY) { - // Use the shortest path along the connections on horizontal sides - if (rightDistance > leftDistance) { - x = Math.min(sOffsetX, tMarginX0); - } else { - x = Math.max(sOffsetX, tMarginX1); - } - } else { - if (rightDistance > leftDistance) { - x = Math.min(tOffsetX, sMarginX0); - } else { - x = Math.max(tOffsetX, sMarginX1); - } - } + return getVerticalSameSideRoutePoints(source, target, sourceOffsetPoint, targetOffsetPoint, { + y1, + y2, + isSourceFurtherOut: tOffsetY >= sOffsetY, + isLeftShorter: leftDistance < rightDistance + }); - return [ - { x: sOffsetX, y: y2 }, - { x, y: y2 }, - { x, y: y1 }, - { x: tOffsetX, y: y1 } - ]; } else if (sourceSide === 'bottom' && targetSide === 'bottom') { const useUShapeConnection = targetInSourceBBox || @@ -600,31 +882,16 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - let x; const y1 = Math.max((sBoxY0 + tBoxY1) / 2, tOffsetY); const y2 = Math.max((sBoxY1 + tBoxY0) / 2, sOffsetY); - if (tOffsetY > sOffsetY) { - // Use the shortest path along the connections on horizontal sides - if (rightDistance > leftDistance) { - x = Math.min(sOffsetX, tMarginX0); - } else { - x = Math.max(sOffsetX, tMarginX1); - } - } else { - if (rightDistance > leftDistance) { - x = Math.min(tOffsetX, sMarginX0); - } else { - x = Math.max(tOffsetX, sMarginX1); - } - } + return getVerticalSameSideRoutePoints(source, target, sourceOffsetPoint, targetOffsetPoint, { + y1, + y2, + isSourceFurtherOut: tOffsetY <= sOffsetY, + isLeftShorter: leftDistance < rightDistance + }); - return [ - { x: sOffsetX, y: y2 }, - { x, y: y2 }, - { x, y: y1 }, - { x: tOffsetX, y: y1 } - ]; } else if (sourceSide === 'left' && targetSide === 'left') { const useUShapeConnection = targetInSourceBBox || @@ -640,30 +907,16 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - let y; const x1 = Math.min((sBoxX1 + tBoxX0) / 2, tOffsetX); const x2 = Math.min((sBoxX0 + tBoxX1) / 2, sOffsetX); - if (tOffsetX > sOffsetX) { - if (topDistance <= bottomDistance) { - y = Math.min(sMarginY0, tOffsetY); - } else { - y = Math.max(sMarginY1, tOffsetY); - } - } else { - if (topDistance <= bottomDistance) { - y = Math.min(tMarginY0, sOffsetY); - } else { - y = Math.max(tMarginY1, sOffsetY); - } - } + return getHorizontalSameSideRoutePoints(source, target, sourceOffsetPoint, targetOffsetPoint, { + x1, + x2, + isSourceFurtherOut: tOffsetX > sOffsetX, + isUpwardsShorter: topDistance <= bottomDistance + }); - return [ - { x: x2, y: sOffsetY }, - { x: x2, y }, - { x: x1, y }, - { x: x1, y: tOffsetY } - ]; } else if (sourceSide === 'right' && targetSide === 'right') { const useUShapeConnection = targetInSourceBBox || @@ -679,30 +932,16 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - let y; const x1 = Math.max((sBoxX0 + tBoxX1) / 2, tOffsetX); const x2 = Math.max((sBoxX1 + tBoxX0) / 2, sOffsetX); - if (tOffsetX <= sOffsetX) { - if (topDistance <= bottomDistance) { - y = Math.min(sMarginY0, tOffsetY); - } else { - y = Math.max(sMarginY1, tOffsetY); - } - } else { - if (topDistance <= bottomDistance) { - y = Math.min(tMarginY0, sOffsetY); - } else { - y = Math.max(tMarginY1, sOffsetY); - } - } + return getHorizontalSameSideRoutePoints(source, target, sourceOffsetPoint, targetOffsetPoint, { + x1, + x2, + isSourceFurtherOut: tOffsetX <= sOffsetX, + isUpwardsShorter: topDistance <= bottomDistance + }); - return [ - { x: x2, y: sOffsetY }, - { x: x2, y }, - { x: x1, y }, - { x: x1, y: tOffsetY } - ]; } else if (sourceSide === 'top' && targetSide === 'right') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetPoint); @@ -732,41 +971,15 @@ export function rightAnglePath(source, target, opt = {}) { } if (sMarginY0 > tOffsetY) { - if (sOffsetX < tOffsetX) { - let y = tMarginY0; - - if (tMinMarginY1 <= sMinMarginY0 && tMarginX1 >= sOffsetX) { - y = middleOfHorizontalSides; - - if (sOffsetY < tMinMarginY1) { - - if (sOffsetX + sourceMargin > tBoxX1) { - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: tOffsetX, y: sOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX + sourceMargin, y: sOffsetY }, - { x: sOffsetX + sourceMargin, y }, - { x: tOffsetX, y }, - { x: tOffsetX, y: tOffsetY } - ]; - } - } + let y = tMarginY0; + let canTurnAtSource = false; - return [ - { x: sOffsetX, y }, - { x: tOffsetX, y }, - { x: tOffsetX, y: tOffsetY } - ]; + if (tMinMarginY1 <= sMinMarginY0 && tMarginX1 >= sOffsetX) { + y = middleOfHorizontalSides; + canTurnAtSource = sOffsetY < tMinMarginY1; } - return [{ x: sOffsetX, y: tOffsetY }]; + return getSideToRightPoints(targetBBox, sourceOffsetPoint, targetOffsetPoint, sourceMargin, y, canTurnAtSource); } const x = Math.max(middleOfVerticalSides, tMinMarginX1); @@ -789,21 +1002,8 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - if (tOffsetX > sMinMarginX0) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x, y: sOffsetY }, - { x, y: tOffsetY - targetMargin }, - { x: tOffsetX, y: tOffsetY - targetMargin }, - { x: tOffsetX, y: tOffsetY } - ]; - } + return getSideToLeftOrRightPoints(sourceOffsetPoint, targetOffsetPoint, x, tOffsetY - targetMargin, tOffsetX > sMinMarginX0); - return [ - { x: sOffsetX, y: sOffsetY }, - { x, y: sOffsetY }, - { x, y: tOffsetY } - ]; } else if (sourceSide === 'top' && targetSide === 'left') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetPoint); @@ -833,41 +1033,15 @@ export function rightAnglePath(source, target, opt = {}) { } if (sMarginY0 > tOffsetY) { - if (sOffsetX > tOffsetX) { - let y = tMarginY0; - - if (tMinMarginY1 <= sMinMarginY0 && tMarginX0 <= sOffsetX) { - y = middleOfHorizontalSides; - - if (sOffsetY < tMinMarginY1) { - - if (sOffsetX - sourceMargin < tBoxX0) { - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: tOffsetX, y: sOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX - sourceMargin, y: sOffsetY }, - { x: sOffsetX - sourceMargin, y }, - { x: tOffsetX, y }, - { x: tOffsetX, y: tOffsetY } - ]; - } - } + let y = tMarginY0; + let canTurnAtSource = false; - return [ - { x: sOffsetX, y }, - { x: tOffsetX, y }, - { x: tOffsetX, y: tOffsetY } - ]; + if (tMinMarginY1 <= sMinMarginY0 && tMarginX0 <= sOffsetX) { + y = middleOfHorizontalSides; + canTurnAtSource = sOffsetY < tMinMarginY1; } - return [{ x: sOffsetX, y: tOffsetY }]; + return getSideToLeftPoints(targetBBox, sourceOffsetPoint, targetOffsetPoint, sourceMargin, y, canTurnAtSource); } const x = Math.min(tMinMarginX0, middleOfVerticalSides); @@ -889,21 +1063,8 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - if (tOffsetX < sMinMarginX1) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x, y: sOffsetY }, - { x, y: tOffsetY - targetMargin }, - { x: tOffsetX, y: tOffsetY - targetMargin }, - { x: tOffsetX, y: tOffsetY } - ]; - } + return getSideToLeftOrRightPoints(sourceOffsetPoint, targetOffsetPoint, x, tOffsetY - targetMargin, tOffsetX < sMinMarginX1); - return [ - { x: sOffsetX, y: sOffsetY }, - { x, y: sOffsetY }, - { x, y: tOffsetY } - ]; } else if (sourceSide === 'bottom' && targetSide === 'right') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetPoint); @@ -933,42 +1094,15 @@ export function rightAnglePath(source, target, opt = {}) { } if (sMarginY1 < tOffsetY) { - if (sOffsetX < tOffsetX) { - let y = tMarginY1; - - if (tMinMarginY0 >= sMinMarginY1 && tMarginX1 >= sOffsetX) { - y = middleOfHorizontalSides; + let y = tMarginY1; + let canTurnAtSource = false; - if (sOffsetY > tMinMarginY0) { - - if (sOffsetX + sourceMargin > tBoxX1) { - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: tOffsetX, y: sOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX + sourceMargin, y: sOffsetY }, - { x: sOffsetX + sourceMargin, y }, - { x: tOffsetX, y }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - } - - return [ - { x: sOffsetX, y }, - { x: tOffsetX, y }, - { x: tOffsetX, y: tOffsetY } - ]; + if (tMinMarginY0 >= sMinMarginY1 && tMarginX1 >= sOffsetX) { + y = middleOfHorizontalSides; + canTurnAtSource = sOffsetY > tMinMarginY0; } - return [{ x: sOffsetX, y: tOffsetY }]; + return getSideToRightPoints(targetBBox, sourceOffsetPoint, targetOffsetPoint, sourceMargin, y, canTurnAtSource); } const x = Math.max(middleOfVerticalSides, tMinMarginX1); @@ -991,21 +1125,8 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - if (tOffsetX > sMinMarginX0) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x, y: sOffsetY }, - { x, y: tOffsetY + targetMargin }, - { x: tOffsetX, y: tOffsetY + targetMargin }, - { x: tOffsetX, y: tOffsetY } - ]; - } + return getSideToLeftOrRightPoints(sourceOffsetPoint, targetOffsetPoint, x, tOffsetY + targetMargin, tOffsetX > sMinMarginX0); - return [ - { x: sOffsetX, y: sOffsetY }, - { x, y: sOffsetY }, - { x, y: tOffsetY } - ]; } else if (sourceSide === 'bottom' && targetSide === 'left') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetPoint); @@ -1035,41 +1156,15 @@ export function rightAnglePath(source, target, opt = {}) { } if (sMarginY1 < tOffsetY) { - if (sOffsetX > tOffsetX) { - let y = tMarginY1; - - if (tMinMarginY0 >= sMinMarginY1 && tMarginX0 <= sOffsetX) { - y = middleOfHorizontalSides; - - if (sOffsetY > tMinMarginY0) { - - if (sOffsetX - sourceMargin < tBoxX0) { - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: tOffsetX, y: sOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX - sourceMargin, y: sOffsetY }, - { x: sOffsetX - sourceMargin, y }, - { x: tOffsetX, y }, - { x: tOffsetX, y: tOffsetY } - ]; - } - } + let y = tMarginY1; + let canTurnAtSource = false; - return [ - { x: sOffsetX, y }, - { x: tOffsetX, y }, - { x: tOffsetX, y: tOffsetY } - ]; + if (tMinMarginY0 >= sMinMarginY1 && tMarginX0 <= sOffsetX) { + y = middleOfHorizontalSides; + canTurnAtSource = sOffsetY > tMinMarginY0; } - return [{ x: sOffsetX, y: tOffsetY }]; + return getSideToLeftPoints(targetBBox, sourceOffsetPoint, targetOffsetPoint, sourceMargin, y, canTurnAtSource); } const x = Math.min(tMinMarginX0, middleOfVerticalSides); @@ -1092,21 +1187,8 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - if (tOffsetX < sMinMarginX1) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x, y: sOffsetY }, - { x, y: tOffsetY + targetMargin }, - { x: tOffsetX, y: tOffsetY + targetMargin }, - { x: tOffsetX, y: tOffsetY } - ]; - } + return getSideToLeftOrRightPoints(sourceOffsetPoint, targetOffsetPoint, x, tOffsetY + targetMargin, tOffsetX < sMinMarginX1); - return [ - { x: sOffsetX, y: sOffsetY }, - { x, y: sOffsetY }, - { x, y: tOffsetY } - ]; } else if (sourceSide === 'left' && targetSide === 'bottom') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetPoint); @@ -1134,42 +1216,15 @@ export function rightAnglePath(source, target, opt = {}) { } if (sMarginX0 > tOffsetX) { - if (sOffsetY < tOffsetY) { - let x = tMarginX0; - - if (tMinMarginX1 <= sMinMarginX0 && tMarginY1 >= sOffsetY) { - x = middleOfVerticalSides; - - if (sOffsetX < tMinMarginX1) { - - if (sOffsetY + sourceMargin > tBoxY1) { + let x = tMarginX0; + let canTurnAtSource = false; - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: sOffsetY + sourceMargin }, - { x, y: sOffsetY + sourceMargin }, - { x, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - } - - return [ - { x, y: sOffsetY }, - { x, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; + if (tMinMarginX1 <= sMinMarginX0 && tMarginY1 >= sOffsetY) { + x = middleOfVerticalSides; + canTurnAtSource = sOffsetX < tMinMarginX1; } - return [{ x: tOffsetX, y: sOffsetY }]; + return getSideToBottomPoints(targetBBox, sourceOffsetPoint, targetOffsetPoint, sourceMargin, x, canTurnAtSource); } const y = Math.max(tMinMarginY1, middleOfHorizontalSides); @@ -1193,21 +1248,8 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - if (tOffsetY > sMinMarginY0) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y }, - { x: tOffsetX - sourceMargin, y }, - { x: tOffsetX - sourceMargin, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } + return getSideToTopOrBottomPoints(sourceOffsetPoint, targetOffsetPoint, y, tOffsetX - sourceMargin, tOffsetY > sMinMarginY0); - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y }, - { x: tOffsetX, y } - ]; } else if (sourceSide === 'left' && targetSide === 'top') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetPoint); @@ -1237,41 +1279,15 @@ export function rightAnglePath(source, target, opt = {}) { } if (sMarginX0 > tOffsetX) { - if (sOffsetY > tOffsetY) { - let x = tMarginX0; - - if (tMinMarginX1 <= sMinMarginX0 && tMarginY0 <= sOffsetY) { - x = middleOfVerticalSides; - - if (sOffsetX < tMinMarginX1) { - - if (sOffsetY - sourceMargin < tBoxY0) { - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: sOffsetY - sourceMargin }, - { x, y: sOffsetY - sourceMargin }, - { x, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - } + let x = tMarginX0; + let canTurnAtSource = false; - return [ - { x, y: sOffsetY }, - { x, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; + if (tMinMarginX1 <= sMinMarginX0 && tMarginY0 <= sOffsetY) { + x = middleOfVerticalSides; + canTurnAtSource = sOffsetX < tMinMarginX1; } - return [{ x: tOffsetX, y: sOffsetY }]; + return getSideToTopPoints(targetBBox, sourceOffsetPoint, targetOffsetPoint, sourceMargin, x, canTurnAtSource); } const y = Math.min(tMinMarginY0, middleOfHorizontalSides); @@ -1293,21 +1309,8 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - if (tOffsetY < sMinMarginY1) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y }, - { x: tOffsetX - sourceMargin, y }, - { x: tOffsetX - sourceMargin, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } + return getSideToTopOrBottomPoints(sourceOffsetPoint, targetOffsetPoint, y, tOffsetX - sourceMargin, tOffsetY < sMinMarginY1); - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y }, - { x: tOffsetX, y } - ]; } else if (sourceSide === 'right' && targetSide === 'top') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetPoint); @@ -1337,41 +1340,15 @@ export function rightAnglePath(source, target, opt = {}) { } if (sMarginX1 < tOffsetX) { - if (sOffsetY > tOffsetY) { - let x = tMarginX1; - - if (tMinMarginX0 >= sMinMarginX1 && tMarginY0 <= sOffsetY) { - x = middleOfVerticalSides; - - if (sOffsetX > tMinMarginX0) { - - if (sOffsetY - sourceMargin < tBoxY0) { - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: sOffsetY - sourceMargin }, - { x, y: sOffsetY - sourceMargin }, - { x, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - } + let x = tMarginX1; + let canTurnAtSource = false; - return [ - { x, y: sOffsetY }, - { x, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; + if (tMinMarginX0 >= sMinMarginX1 && tMarginY0 <= sOffsetY) { + x = middleOfVerticalSides; + canTurnAtSource = sOffsetX > tMinMarginX0; } - return [{ x: tOffsetX, y: sOffsetY }]; + return getSideToTopPoints(targetBBox, sourceOffsetPoint, targetOffsetPoint, sourceMargin, x, canTurnAtSource); } const y = Math.min(tMinMarginY0, middleOfHorizontalSides); @@ -1394,21 +1371,8 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - if (tOffsetY < sMinMarginY1) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y }, - { x: tOffsetX + sourceMargin, y }, - { x: tOffsetX + sourceMargin, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } + return getSideToTopOrBottomPoints(sourceOffsetPoint, targetOffsetPoint, y, tOffsetX + sourceMargin, tOffsetY < sMinMarginY1); - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y }, - { x: tOffsetX, y } - ]; } else if (sourceSide === 'right' && targetSide === 'bottom') { const isPointInsideSource = inflatedSourceBBox.containsPoint(targetPoint); @@ -1436,42 +1400,15 @@ export function rightAnglePath(source, target, opt = {}) { } if (sMarginX1 < tOffsetX) { - if (sOffsetY < tOffsetY) { - let x = tMarginX1; - - if (tMinMarginX0 >= sMinMarginX1 && tMarginY1 >= sOffsetY) { - x = middleOfVerticalSides; + let x = tMarginX1; + let canTurnAtSource = false; - if (sOffsetX > tMinMarginX0) { - - if (sOffsetY + sourceMargin > tBoxY1) { - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: sOffsetY + sourceMargin }, - { x, y: sOffsetY + sourceMargin }, - { x, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - } - - return [ - { x, y: sOffsetY }, - { x, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; + if (tMinMarginX0 >= sMinMarginX1 && tMarginY1 >= sOffsetY) { + x = middleOfVerticalSides; + canTurnAtSource = sOffsetX > tMinMarginX0; } - return [{ x: tOffsetX, y: sOffsetY }]; + return getSideToBottomPoints(targetBBox, sourceOffsetPoint, targetOffsetPoint, sourceMargin, x, canTurnAtSource); } const y = Math.max(tMinMarginY1, middleOfHorizontalSides); @@ -1495,20 +1432,6 @@ export function rightAnglePath(source, target, opt = {}) { ]; } - if (tOffsetY > sMinMarginY0) { - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y }, - { x: tOffsetX + sourceMargin, y }, - { x: tOffsetX + sourceMargin, y: tOffsetY }, - { x: tOffsetX, y: tOffsetY } - ]; - } - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y }, - { x: tOffsetX, y } - ]; + return getSideToTopOrBottomPoints(sourceOffsetPoint, targetOffsetPoint, y, tOffsetX + sourceMargin, tOffsetY > sMinMarginY0); } } From 19e0a1bc1c945c1e715d809a0317f8bad9406e4f Mon Sep 17 00:00:00 2001 From: zbynekstara Date: Thu, 20 Aug 2026 17:15:06 +0200 Subject: [PATCH 2/6] add two more helpers --- .../joint-core/src/alg/rightAnglePath.mjs | 104 +++++++----------- 1 file changed, 42 insertions(+), 62 deletions(-) diff --git a/packages/joint-core/src/alg/rightAnglePath.mjs b/packages/joint-core/src/alg/rightAnglePath.mjs index 95321759cb..c1f2a025a0 100644 --- a/packages/joint-core/src/alg/rightAnglePath.mjs +++ b/packages/joint-core/src/alg/rightAnglePath.mjs @@ -551,6 +551,36 @@ function getSideToLeftOrRightPoints(sourcePoint, targetPoint, x, approachY, need ]; } +// Assembles the points of the route crossing halfway between the two offset points, along the +// vertical segment at their middle `x`. The `top` and `bottom` source branches fall back to it +// when the target point lies inside the source element and there is no room to route around it. +function getMiddleXPoints(sourcePoint, targetPoint) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + const middleX = (sOffsetX + tOffsetX) / 2; + + return [ + { x: sOffsetX, y: sOffsetY }, + { x: middleX, y: sOffsetY }, + { x: middleX, y: tOffsetY } + ]; +} + +// Assembles the points of the route crossing halfway between the two offset points, along the +// horizontal segment at their middle `y`. The `left` and `right` source branches fall back to it +// when the target point lies inside the source element and there is no room to route around it. +function getMiddleYPoints(sourcePoint, targetPoint) { + const { x: sOffsetX, y: sOffsetY } = sourcePoint; + const { x: tOffsetX, y: tOffsetY } = targetPoint; + const middleY = (sOffsetY + tOffsetY) / 2; + + return [ + { x: sOffsetX, y: sOffsetY }, + { x: sOffsetX, y: middleY }, + { x: tOffsetX, y: middleY } + ]; +} + export function rightAnglePath(source, target, opt = {}) { const { bbox: sourceBBox, @@ -947,6 +977,7 @@ export function rightAnglePath(source, target, opt = {}) { // The target point is inside the source element if (isPointInsideSource) { + // Subtract the `sourceMargin` since the source anchor is on the right side of the target anchor if (sOffsetX <= tOffsetX - sourceMargin) { const x = Math.max(sMarginX1, tOffsetX); const y = Math.min(sMarginY0, tMarginY0); @@ -960,14 +991,7 @@ export function rightAnglePath(source, target, opt = {}) { } // Target anchor is on the left side of the source anchor - // Subtract the `sourceMargin` since the source anchor is on the right side of the target anchor - const anchorMiddleX = (sOffsetX + tOffsetX) / 2; - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: anchorMiddleX, y: sOffsetY }, - { x: anchorMiddleX, y: tOffsetY } - ]; + return getMiddleXPoints(sourceOffsetPoint, targetOffsetPoint); } if (sMarginY0 > tOffsetY) { @@ -1009,6 +1033,7 @@ export function rightAnglePath(source, target, opt = {}) { // The target point is inside the source element if (isPointInsideSource) { + // Add the `sourceMargin` since the source anchor is on the left side of the target anchor if (sOffsetX >= tOffsetX + sourceMargin) { const x = Math.min(sMarginX0, tOffsetX); const y = Math.min(sMarginY0, tMarginY0); @@ -1022,14 +1047,7 @@ export function rightAnglePath(source, target, opt = {}) { } // Target anchor is on the right side of the source anchor - // Add the `sourceMargin` since the source anchor is on the left side of the target anchor - const anchorMiddleX = (sOffsetX + tOffsetX) / 2; - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: anchorMiddleX, y: sOffsetY }, - { x: anchorMiddleX, y: tOffsetY } - ]; + return getMiddleXPoints(sourceOffsetPoint, targetOffsetPoint); } if (sMarginY0 > tOffsetY) { @@ -1070,6 +1088,7 @@ export function rightAnglePath(source, target, opt = {}) { // The target point is inside the source element if (isPointInsideSource) { + // Subtract the `sourceMargin` since the source anchor is on the right side of the target anchor if (sOffsetX <= tOffsetX - sourceMargin) { const x = Math.max(sMarginX1, tOffsetX); const y = Math.max(sMarginY1, tMarginY1); @@ -1083,14 +1102,7 @@ export function rightAnglePath(source, target, opt = {}) { } // Target anchor is on the left side of the source anchor - // Subtract the `sourceMargin` since the source anchor is on the right side of the target anchor - const anchorMiddleX = (sOffsetX + tOffsetX) / 2; - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: anchorMiddleX, y: sOffsetY }, - { x: anchorMiddleX, y: tOffsetY } - ]; + return getMiddleXPoints(sourceOffsetPoint, targetOffsetPoint); } if (sMarginY1 < tOffsetY) { @@ -1132,6 +1144,7 @@ export function rightAnglePath(source, target, opt = {}) { // The target point is inside the source element if (isPointInsideSource) { + // Add the `sourceMargin` since the source anchor is on the left side of the target anchor if (sOffsetX >= tOffsetX + sourceMargin) { const x = Math.min(sOffsetX - sourceMargin, tOffsetX); const y = Math.max(sMarginY1, tMarginY1); @@ -1145,14 +1158,7 @@ export function rightAnglePath(source, target, opt = {}) { } // Target anchor is on the right side of the source anchor - // Add the `sourceMargin` since the source anchor is on the left side of the target anchor - const anchorMiddleX = (sOffsetX + tOffsetX) / 2; - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: anchorMiddleX, y: sOffsetY }, - { x: anchorMiddleX, y: tOffsetY } - ]; + return getMiddleXPoints(sourceOffsetPoint, targetOffsetPoint); } if (sMarginY1 < tOffsetY) { @@ -1206,13 +1212,7 @@ export function rightAnglePath(source, target, opt = {}) { } // Target anchor is above the source anchor - const anchorMiddleY = (sOffsetY + tOffsetY) / 2; - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: anchorMiddleY }, - { x: tOffsetX, y: anchorMiddleY } - ]; + return getMiddleYPoints(sourceOffsetPoint, targetOffsetPoint); } if (sMarginX0 > tOffsetX) { @@ -1268,14 +1268,7 @@ export function rightAnglePath(source, target, opt = {}) { } // Target anchor is below the source anchor - // Add the `sourceMargin` since the source anchor is above the target anchor - const anchorMiddleY = (sOffsetY + tOffsetY) / 2; - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: anchorMiddleY }, - { x: tOffsetX, y: anchorMiddleY } - ]; + return getMiddleYPoints(sourceOffsetPoint, targetOffsetPoint); } if (sMarginX0 > tOffsetX) { @@ -1329,14 +1322,7 @@ export function rightAnglePath(source, target, opt = {}) { } // Target anchor is below the source anchor - // Adjust sourceMargin calculation since the source anchor is now on the right - const anchorMiddleY = (sOffsetY + tOffsetY) / 2; - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: anchorMiddleY }, - { x: tOffsetX, y: anchorMiddleY } - ]; + return getMiddleYPoints(sourceOffsetPoint, targetOffsetPoint); } if (sMarginX1 < tOffsetX) { @@ -1390,13 +1376,7 @@ export function rightAnglePath(source, target, opt = {}) { } // Target anchor is above the source anchor - const anchorMiddleY = (sOffsetY + tOffsetY) / 2; - - return [ - { x: sOffsetX, y: sOffsetY }, - { x: sOffsetX, y: anchorMiddleY }, - { x: tOffsetX, y: anchorMiddleY } - ]; + return getMiddleYPoints(sourceOffsetPoint, targetOffsetPoint); } if (sMarginX1 < tOffsetX) { From ab3afb6a459be0f647209801b7b477f07f9afd14 Mon Sep 17 00:00:00 2001 From: zbynekstara Date: Thu, 20 Aug 2026 17:31:05 +0200 Subject: [PATCH 3/6] whitespace fix --- packages/joint-core/src/alg/rightAnglePath.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/joint-core/src/alg/rightAnglePath.mjs b/packages/joint-core/src/alg/rightAnglePath.mjs index c1f2a025a0..ca5f59a803 100644 --- a/packages/joint-core/src/alg/rightAnglePath.mjs +++ b/packages/joint-core/src/alg/rightAnglePath.mjs @@ -35,7 +35,6 @@ function getOutsidePoint(side, pointData) { // Calculates the distances along the horizontal axis for the left and right route. function getHorizontalDistance(source, target) { - const { outsidePoint: sourcePoint } = source; const { outsidePoint: targetPoint } = target; From f9ad242756d27d4d7ab09c97194a6061f74f4456 Mon Sep 17 00:00:00 2001 From: zbynekstara Date: Thu, 20 Aug 2026 17:51:48 +0200 Subject: [PATCH 4/6] add empty changeset --- .changeset/fair-wasps-cheer.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fair-wasps-cheer.md diff --git a/.changeset/fair-wasps-cheer.md b/.changeset/fair-wasps-cheer.md new file mode 100644 index 0000000000..f0184f387e --- /dev/null +++ b/.changeset/fair-wasps-cheer.md @@ -0,0 +1,6 @@ +--- + +--- + + + \ No newline at end of file From 79b1ba200c1b6aebd3223788959abb75cadc2b9e Mon Sep 17 00:00:00 2001 From: zbynekstara Date: Thu, 20 Aug 2026 18:52:34 +0200 Subject: [PATCH 5/6] add tests to ensure same results as before refactor --- .../test/jointjs/alg.rightAnglePath.js | 961 ++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 packages/joint-core/test/jointjs/alg.rightAnglePath.js diff --git a/packages/joint-core/test/jointjs/alg.rightAnglePath.js b/packages/joint-core/test/jointjs/alg.rightAnglePath.js new file mode 100644 index 0000000000..9301b88d8b --- /dev/null +++ b/packages/joint-core/test/jointjs/alg.rightAnglePath.js @@ -0,0 +1,961 @@ +// Characterization tests for `joint.alg.rightAnglePath()`, the path-finding +// algorithm behind the `rightAngle` router. +// +// The algorithm decides between 16 combinations of the side the route leaves the +// source through and the side it arrives at the target through, and reaches one +// of 107 `return` statements. What comes out is a link's shape, so almost +// everything asserted here is a route recorded from a known-good implementation +// rather than one derived from first principles. The point is to pin the +// behaviour down while the algorithm is refactored: a failure means some route +// changed, and the assertion message says which case. +// +// - `routes` walks a set of 80 cases that between them reach every one of +// those 107 returns. Regenerate it (and check the diff by eye) if a change +// to the routes is intended. +// - `edge cases` pins the boundaries the algorithm decides on - overlapping +// and touching elements, shared centers, equal distances, zero sizes, and +// the two options. +// - `properties` checks what has to hold for any input at all, over a +// generated sweep. These are derived from the contract, not recorded. + +QUnit.module('alg.rightAnglePath', function() { + + // A case's ends are given as `rect: [x, y, width, height]`, the `margin` + // kept around it, the `side` the route goes through and the `anchor` the + // route runs to - the same shape `rightAnglePath()` takes, spelled shorter. + function makeEnd({ rect, margin, side, anchor }) { + return { + endPoint: new g.Point(anchor[0], anchor[1]), + bbox: new g.Rect(rect[0], rect[1], rect[2], rect[3]), + margin: margin, + side: side + }; + } + + function route(testCase) { + const points = joint.alg.rightAnglePath( + makeEnd(testCase.source), + makeEnd(testCase.target), + testCase.opt || {} + ); + // Compared as plain pairs so a failure prints readably. + return points.map(function(point) { + return [point.x, point.y]; + }); + } + + function describe(testCase, index) { + const opt = testCase.opt ? ` ${JSON.stringify(testCase.opt)}` : ''; + return testCase.name || `case ${index}: source ${JSON.stringify(testCase.source.rect)} margin ${testCase.source.margin}, ` + + `target ${JSON.stringify(testCase.target.rect)} margin ${testCase.target.margin}${opt}`; + } + + QUnit.module('routes', function() { + + QUnit.test('left => left', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 20, 60], margin: 20, side: 'left', anchor: [5, 45] }, + target: { rect: [10, 40, 20, 0], margin: 0, side: 'left', anchor: [10, 40] }, + expected: [[-20, 45], [-20, 40]] + }, + { + source: { rect: [0, 0, 0, 20], margin: 10, side: 'left', anchor: [0, 20] }, + target: { rect: [50, 0, 40, 40], margin: 10, side: 'left', anchor: [50, 20] }, + expected: [[-10, 20], [-10, 30], [25, 30], [25, 20]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('left => right', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 0, 20], margin: 10, side: 'left', anchor: [0, 20] }, + target: { rect: [-10, -30, 20, 60], margin: 20, side: 'right', anchor: [10, -30] }, + expected: [[-10, 20], [-10, -5], [30, -5], [30, -30]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 20, side: 'left', anchor: [0, 0] }, + target: { rect: [-60, -20, 0, 60], margin: 20, side: 'right', anchor: [-60, 10] }, + expected: [[-30, 0], [-30, 10]] + }, + { + source: { rect: [0, 0, 40, 0], margin: 10, side: 'left', anchor: [10, 0] }, + target: { rect: [10, 60, 60, 40], margin: 20, side: 'right', anchor: [70, 60] }, + expected: [[-10, 0], [-10, 30], [90, 30], [90, 60]] + }, + { + source: { rect: [0, 0, 20, 60], margin: 10, side: 'left', anchor: [0, 60] }, + target: { rect: [-40, -60, 20, 60], margin: 20, side: 'right', anchor: [-30, -30] }, + opt: { minPathMargin: 5 }, + expected: [[-10, 60], [-10, 15], [0, 15], [0, -30]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('left => top', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 60, 0], margin: 20, side: 'left', anchor: [0, 0] }, + target: { rect: [-50, -50, 40, 60], margin: 20, side: 'top', anchor: [-30, -20] }, + expected: [[-70, 0], [-70, -70], [-30, -70]] + }, + { + source: { rect: [0, 0, 60, 60], margin: 20, side: 'left', anchor: [0, 0] }, + target: { rect: [40, -50, 60, 60], margin: 0, side: 'top', anchor: [70, -50] }, + expected: [[-20, 0], [-20, -50], [70, -50]] + }, + { + source: { rect: [0, 0, 40, 60], margin: 20, side: 'left', anchor: [0, 30] }, + target: { rect: [0, 30, 20, 0], margin: 20, side: 'top', anchor: [10, 30] }, + expected: [[-20, 30], [-20, -20], [10, -20]] + }, + { + source: { rect: [0, 0, 40, 0], margin: 20, side: 'left', anchor: [0, 0] }, + target: { rect: [50, 80, 60, 20], margin: 0, side: 'top', anchor: [80, 90] }, + expected: [[-20, 0], [-20, 40], [80, 40]] + }, + { + source: { rect: [0, 0, 20, 20], margin: 20, side: 'left', anchor: [0, 0] }, + target: { rect: [0, 40, 20, 40], margin: 0, side: 'top', anchor: [20, 40] }, + expected: [[-20, 0], [-20, 20], [20, 20]] + }, + { + source: { rect: [0, 0, 40, 40], margin: 10, side: 'left', anchor: [0, 20] }, + target: { rect: [-10, 60, 40, 20], margin: 0, side: 'top', anchor: [0, 60] }, + expected: [[-10, 20], [-10, 50], [0, 50]] + }, + { + source: { rect: [0, 0, 20, 60], margin: 20, side: 'left', anchor: [0, 60] }, + target: { rect: [-60, 50, 40, 20], margin: 20, side: 'top', anchor: [-50, 50] }, + opt: { minPathMargin: 10 }, + expected: [[-20, 60], [-20, 30], [-50, 30]] + }, + { + source: { rect: [0, 0, 40, 60], margin: 10, side: 'left', anchor: [0, 60] }, + target: { rect: [-60, -30, 60, 20], margin: 0, side: 'top', anchor: [-60, -30] }, + opt: { minPathMargin: 0 }, + expected: [[-10, 60], [-10, 50], [0, 50], [0, -30], [-60, -30]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('left => bottom', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 60, 60], margin: 20, side: 'left', anchor: [0, 60] }, + target: { rect: [-60, 30, 20, 0], margin: 20, side: 'bottom', anchor: [-40, 30] }, + expected: [[-40, 60]] + }, + { + source: { rect: [0, 0, 0, 40], margin: 20, side: 'left', anchor: [0, 10] }, + target: { rect: [40, 40, 0, 60], margin: 20, side: 'bottom', anchor: [40, 100] }, + expected: [[-20, 10], [-20, 120], [40, 120]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 20, side: 'left', anchor: [0, 20] }, + target: { rect: [50, -60, 20, 0], margin: 10, side: 'bottom', anchor: [50, -60] }, + expected: [[-20, 20], [-20, -30], [50, -30]] + }, + { + source: { rect: [0, 0, 60, 40], margin: 20, side: 'left', anchor: [15, 30] }, + target: { rect: [10, 0, 40, 20], margin: 20, side: 'bottom', anchor: [30, 20] }, + expected: [[-20, 30], [-20, 40], [30, 40]] + }, + { + source: { rect: [0, 0, 40, 40], margin: 20, side: 'left', anchor: [0, 40] }, + target: { rect: [60, -30, 0, 40], margin: 10, side: 'bottom', anchor: [60, 10] }, + expected: [[-20, 40], [-20, 30], [60, 30]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 0, side: 'left', anchor: [0, 20] }, + target: { rect: [40, -60, 60, 0], margin: 20, side: 'bottom', anchor: [100, -60] }, + expected: [[0, 20], [0, -30], [100, -30]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 10, side: 'left', anchor: [30, 10] }, + target: { rect: [-50, -60, 40, 60], margin: 20, side: 'bottom', anchor: [-40, 0] }, + opt: { minPathMargin: 5 }, + expected: [[-10, 10], [-10, 20], [-40, 20]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('right => left', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 20, 40], margin: 10, side: 'right', anchor: [20, 20] }, + target: { rect: [60, 80, 40, 60], margin: 20, side: 'left', anchor: [60, 140] }, + expected: [[35, 20], [35, 140]] + }, + { + source: { rect: [0, 0, 40, 20], margin: 0, side: 'right', anchor: [10, 15] }, + target: { rect: [-60, -40, 0, 20], margin: 20, side: 'left', anchor: [-60, -20] }, + expected: [[40, 15], [40, 20], [-80, 20], [-80, -20]] + }, + { + source: { rect: [0, 0, 60, 40], margin: 20, side: 'right', anchor: [60, 40] }, + target: { rect: [80, 10, 20, 20], margin: 0, side: 'left', anchor: [80, 20] }, + expected: [[80, 40], [80, 30], [80, 30], [80, 20]] + }, + { + source: { rect: [0, 0, 40, 40], margin: 20, side: 'right', anchor: [10, 30] }, + target: { rect: [60, -60, 40, 60], margin: 20, side: 'left', anchor: [60, -60] }, + opt: { minPathMargin: 5 }, + expected: [[60, 30], [60, -15], [40, -15], [40, -60]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('right => right', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 40, 60], margin: 20, side: 'right', anchor: [40, 0] }, + target: { rect: [0, -60, 40, 60], margin: 10, side: 'right', anchor: [40, 0] }, + expected: [[60, 0], [60, -20], [50, -20], [50, 0]] + }, + { + source: { rect: [0, 0, 60, 60], margin: 0, side: 'right', anchor: [60, 0] }, + target: { rect: [30, 20, 60, 20], margin: 10, side: 'right', anchor: [90, 20] }, + expected: [[100, 0], [100, 20]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('right => top', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 20, 40], margin: 20, side: 'right', anchor: [20, 40] }, + target: { rect: [60, 60, 40, 0], margin: 20, side: 'top', anchor: [100, 60] }, + expected: [[100, 40]] + }, + { + source: { rect: [0, 0, 60, 0], margin: 20, side: 'right', anchor: [60, 0] }, + target: { rect: [20, 20, 0, 40], margin: 10, side: 'top', anchor: [20, 20] }, + expected: [[80, 0], [80, 5], [20, 5]] + }, + { + source: { rect: [0, 0, 0, 20], margin: 20, side: 'right', anchor: [0, 10] }, + target: { rect: [20, -50, 60, 20], margin: 20, side: 'top', anchor: [20, -30] }, + expected: [[20, 10], [20, -70], [40, -70], [40, -70], [20, -70]] + }, + { + source: { rect: [0, 0, 60, 0], margin: 10, side: 'right', anchor: [60, 0] }, + target: { rect: [20, 30, 40, 0], margin: 20, side: 'top', anchor: [20, 30] }, + expected: [[70, 0], [70, 10], [20, 10]] + }, + { + source: { rect: [0, 0, 20, 20], margin: 20, side: 'right', anchor: [20, 0] }, + target: { rect: [0, 10, 20, 0], margin: 10, side: 'top', anchor: [20, 10] }, + expected: [[40, 0], [40, -20], [20, -20]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 10, side: 'right', anchor: [60, 5] }, + target: { rect: [-80, -80, 60, 20], margin: 0, side: 'top', anchor: [-65, -65] }, + expected: [[70, 5], [70, -80], [-65, -80]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('right => bottom', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 0, 20], margin: 20, side: 'right', anchor: [0, 15] }, + target: { rect: [-20, -60, 40, 20], margin: 0, side: 'bottom', anchor: [-20, -40] }, + expected: [[20, 15], [20, -20], [-20, -20]] + }, + { + source: { rect: [0, 0, 0, 20], margin: 0, side: 'right', anchor: [0, 10] }, + target: { rect: [60, -20, 40, 40], margin: 20, side: 'bottom', anchor: [100, 20] }, + expected: [[30, 10], [30, 40], [100, 40]] + }, + { + source: { rect: [0, 0, 60, 40], margin: 10, side: 'right', anchor: [15, 30] }, + target: { rect: [20, 40, 20, 40], margin: 0, side: 'bottom', anchor: [20, 80] }, + expected: [[70, 30], [70, 80], [20, 80]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 20, side: 'right', anchor: [60, 0] }, + target: { rect: [80, -30, 60, 60], margin: 0, side: 'bottom', anchor: [80, 30] }, + expected: [[140, 0], [140, 30], [80, 30]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 10, side: 'right', anchor: [60, 0] }, + target: { rect: [10, -60, 40, 40], margin: 10, side: 'bottom', anchor: [20, -30] }, + expected: [[70, 0], [70, -10], [20, -10]] + }, + { + source: { rect: [0, 0, 20, 20], margin: 0, side: 'right', anchor: [20, 5] }, + target: { rect: [-20, 0, 20, 0], margin: 0, side: 'bottom', anchor: [0, 0] }, + expected: [[20, 5], [20, 2.5], [0, 2.5]] + }, + { + source: { rect: [0, 0, 40, 0], margin: 20, side: 'right', anchor: [20, 0] }, + target: { rect: [50, -20, 40, 40], margin: 20, side: 'bottom', anchor: [70, 20] }, + opt: { minPathMargin: 0 }, + expected: [[60, 0], [60, 20], [45, 20], [45, 40], [70, 40]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('top => left', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 0, 60], margin: 10, side: 'top', anchor: [0, 0] }, + target: { rect: [20, -40, 20, 0], margin: 20, side: 'left', anchor: [20, -40] }, + expected: [[0, -40]] + }, + { + source: { rect: [0, 0, 0, 0], margin: 10, side: 'top', anchor: [0, 0] }, + target: { rect: [30, -10, 60, 40], margin: 10, side: 'left', anchor: [60, 10] }, + expected: [[0, -10], [15, -10], [15, 10]] + }, + { + source: { rect: [0, 0, 60, 60], margin: 0, side: 'top', anchor: [60, 0] }, + target: { rect: [10, 0, 40, 60], margin: 20, side: 'left', anchor: [10, 0] }, + expected: [[60, -20], [-10, -20], [-10, 0]] + }, + { + source: { rect: [0, 0, 60, 60], margin: 10, side: 'top', anchor: [15, 0] }, + target: { rect: [80, 0, 60, 60], margin: 10, side: 'left', anchor: [80, 15] }, + expected: [[15, -10], [70, -10], [70, 15]] + }, + { + source: { rect: [0, 0, 60, 40], margin: 0, side: 'top', anchor: [60, 0] }, + target: { rect: [-60, -10, 0, 60], margin: 0, side: 'left', anchor: [-60, 50] }, + expected: [[60, -10], [-60, -10], [-60, 50]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 10, side: 'top', anchor: [0, 0] }, + target: { rect: [50, 0, 0, 40], margin: 0, side: 'left', anchor: [50, 20] }, + expected: [[0, -10], [25, -10], [25, 20]] + }, + { + source: { rect: [0, 0, 40, 60], margin: 10, side: 'top', anchor: [40, 0] }, + target: { rect: [50, -20, 60, 20], margin: 20, side: 'left', anchor: [50, -15] }, + opt: { minPathMargin: 0 }, + expected: [[40, -10], [30, -10], [30, -15]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('top => right', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 0, 40], margin: 20, side: 'top', anchor: [0, 0] }, + target: { rect: [-60, -80, 40, 20], margin: 0, side: 'right', anchor: [-20, -75] }, + expected: [[0, -75]] + }, + { + source: { rect: [0, 0, 0, 20], margin: 0, side: 'top', anchor: [0, 0] }, + target: { rect: [50, -60, 0, 60], margin: 0, side: 'right', anchor: [50, 0] }, + expected: [[0, 0], [50, 0], [50, 0], [50, 0], [50, 0]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 10, side: 'top', anchor: [60, 0] }, + target: { rect: [80, 20, 0, 40], margin: 10, side: 'right', anchor: [80, 60] }, + expected: [[60, -10], [90, -10], [90, 60]] + }, + { + source: { rect: [0, 0, 20, 20], margin: 20, side: 'top', anchor: [5, 15] }, + target: { rect: [0, -20, 20, 60], margin: 10, side: 'right', anchor: [20, 40] }, + expected: [[5, -30], [40, -30], [40, 40]] + }, + { + source: { rect: [0, 0, 60, 40], margin: 20, side: 'top', anchor: [15, 0] }, + target: { rect: [-40, -10, 20, 60], margin: 10, side: 'right', anchor: [-20, 50] }, + expected: [[15, -20], [2.5, -20], [2.5, 50]] + }, + { + source: { rect: [0, 0, 20, 0], margin: 0, side: 'top', anchor: [20, 0] }, + target: { rect: [-50, 0, 20, 0], margin: 20, side: 'right', anchor: [-40, 0] }, + expected: [[20, 0], [-10, 0], [-10, 0]] + }, + { + source: { rect: [0, 0, 20, 0], margin: 20, side: 'top', anchor: [10, 0] }, + target: { rect: [-20, -40, 40, 40], margin: 10, side: 'right', anchor: [20, -40] }, + opt: { minPathMargin: 0 }, + expected: [[10, -20], [30, -20], [30, -40]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('top => top', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 60, 40], margin: 0, side: 'top', anchor: [15, 0] }, + target: { rect: [20, -10, 60, 0], margin: 0, side: 'top', anchor: [80, -10] }, + expected: [[15, -10], [80, -10]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 10, side: 'top', anchor: [30, 10] }, + target: { rect: [10, 30, 60, 0], margin: 20, side: 'top', anchor: [25, 30] }, + expected: [[30, -10], [-10, -10], [-10, 10], [25, 10]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('top => bottom', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 40, 0], margin: 0, side: 'top', anchor: [10, 0] }, + target: { rect: [-40, -60, 40, 20], margin: 10, side: 'bottom', anchor: [-30, -45] }, + expected: [[10, -15], [-30, -15]] + }, + { + source: { rect: [0, 0, 40, 0], margin: 20, side: 'top', anchor: [0, 0] }, + target: { rect: [80, 80, 40, 40], margin: 20, side: 'bottom', anchor: [100, 100] }, + expected: [[0, -20], [-20, -20], [-20, 140], [100, 140]] + }, + { + source: { rect: [0, 0, 60, 0], margin: 20, side: 'top', anchor: [60, 0] }, + target: { rect: [20, -20, 20, 0], margin: 0, side: 'bottom', anchor: [25, -20] }, + expected: [[60, -20], [42.5, -20], [42.5, -20], [25, -20]] + }, + { + source: { rect: [0, 0, 60, 60], margin: 20, side: 'top', anchor: [15, 0] }, + target: { rect: [80, -50, 20, 20], margin: 20, side: 'bottom', anchor: [85, -30] }, + opt: { minPathMargin: 10 }, + expected: [[15, -20], [50, -20], [50, -10], [85, -10]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('bottom => left', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 20, 0], margin: 10, side: 'bottom', anchor: [10, 0] }, + target: { rect: [-50, 0, 60, 60], margin: 0, side: 'left', anchor: [-50, 30] }, + expected: [[10, 60], [-50, 60], [-50, 30]] + }, + { + source: { rect: [0, 0, 40, 20], margin: 20, side: 'bottom', anchor: [20, 20] }, + target: { rect: [60, -50, 40, 60], margin: 20, side: 'left', anchor: [60, -20] }, + expected: [[20, 40], [30, 40], [30, -20]] + }, + { + source: { rect: [0, 0, 40, 20], margin: 0, side: 'bottom', anchor: [40, 0] }, + target: { rect: [30, 0, 40, 0], margin: 20, side: 'left', anchor: [30, 0] }, + expected: [[40, 20], [10, 20], [10, 0]] + }, + { + source: { rect: [0, 0, 60, 40], margin: 20, side: 'bottom', anchor: [60, 40] }, + target: { rect: [30, -30, 0, 0], margin: 20, side: 'left', anchor: [30, -30] }, + expected: [[60, 60], [-20, 60], [-20, -30]] + }, + { + source: { rect: [0, 0, 20, 40], margin: 20, side: 'bottom', anchor: [20, 40] }, + target: { rect: [80, -80, 20, 60], margin: 0, side: 'left', anchor: [100, -80] }, + expected: [[20, 60], [50, 60], [50, -80]] + }, + { + source: { rect: [0, 0, 0, 60], margin: 10, side: 'bottom', anchor: [0, 30] }, + target: { rect: [60, 10, 60, 0], margin: 20, side: 'left', anchor: [75, 10] }, + expected: [[0, 70], [30, 70], [30, 10]] + }, + { + source: { rect: [0, 0, 0, 0], margin: 20, side: 'bottom', anchor: [0, 0] }, + target: { rect: [-80, 20, 40, 40], margin: 10, side: 'left', anchor: [-80, 30] }, + opt: { minPathMargin: 10 }, + expected: [[0, 20], [-20, 20], [-20, 10], [-90, 10], [-90, 30]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('bottom => right', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 20, 40], margin: 20, side: 'bottom', anchor: [5, 40] }, + target: { rect: [50, 80, 20, 40], margin: 10, side: 'right', anchor: [70, 120] }, + expected: [[5, 60], [80, 60], [80, 120]] + }, + { + source: { rect: [0, 0, 40, 0], margin: 20, side: 'bottom', anchor: [10, 0] }, + target: { rect: [40, -20, 0, 40], margin: 20, side: 'right', anchor: [40, 0] }, + expected: [[10, 40], [60, 40], [60, 0]] + }, + { + source: { rect: [0, 0, 40, 40], margin: 10, side: 'bottom', anchor: [20, 40] }, + target: { rect: [-30, -10, 0, 0], margin: 20, side: 'right', anchor: [-30, -10] }, + expected: [[20, 50], [-10, 50], [-10, -10]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 10, side: 'bottom', anchor: [15, 15] }, + target: { rect: [10, -40, 40, 20], margin: 0, side: 'right', anchor: [50, -40] }, + expected: [[15, 30], [70, 30], [70, -40]] + }, + { + source: { rect: [0, 0, 60, 0], margin: 20, side: 'bottom', anchor: [0, 0] }, + target: { rect: [0, -20, 0, 20], margin: 0, side: 'right', anchor: [0, -5] }, + expected: [[0, 20], [0, 20], [0, -5]] + }, + { + source: { rect: [0, 0, 60, 40], margin: 10, side: 'bottom', anchor: [0, 40] }, + target: { rect: [-50, 10, 0, 20], margin: 0, side: 'right', anchor: [-50, 25] }, + expected: [[0, 50], [-25, 50], [-25, 25]] + }, + { + source: { rect: [0, 0, 60, 40], margin: 20, side: 'bottom', anchor: [15, 40] }, + target: { rect: [60, 60, 40, 20], margin: 0, side: 'right', anchor: [100, 70] }, + opt: { minPathMargin: 5 }, + expected: [[15, 60], [35, 60], [35, 50], [100, 50], [100, 70]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('bottom => top', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 60, 60], margin: 10, side: 'bottom', anchor: [30, 60] }, + target: { rect: [30, 10, 20, 40], margin: 20, side: 'top', anchor: [50, 10] }, + expected: [[30, 70], [40, 70], [40, -10], [50, -10]] + }, + { + source: { rect: [0, 0, 0, 20], margin: 20, side: 'bottom', anchor: [0, 20] }, + target: { rect: [60, 50, 40, 60], margin: 0, side: 'top', anchor: [80, 80] }, + expected: [[0, 45], [80, 45]] + }, + { + source: { rect: [0, 0, 40, 20], margin: 0, side: 'bottom', anchor: [40, 20] }, + target: { rect: [-60, -10, 60, 40], margin: 10, side: 'top', anchor: [-60, -10] }, + expected: [[40, 20], [40, 20], [40, -20], [-60, -20]] + }, + { + source: { rect: [0, 0, 60, 20], margin: 20, side: 'bottom', anchor: [60, 0] }, + target: { rect: [-40, 40, 60, 60], margin: 20, side: 'top', anchor: [-40, 40] }, + opt: { minPathMargin: 5 }, + expected: [[60, 40], [10, 40], [10, 20], [-40, 20]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + + QUnit.test('bottom => bottom', function(assert) { + + const cases = [ + { + source: { rect: [0, 0, 40, 60], margin: 0, side: 'bottom', anchor: [10, 60] }, + target: { rect: [0, 60, 40, 0], margin: 10, side: 'bottom', anchor: [40, 60] }, + expected: [[10, 60], [50, 60], [50, 70], [40, 70]] + }, + { + source: { rect: [0, 0, 0, 0], margin: 0, side: 'bottom', anchor: [0, 0] }, + target: { rect: [60, 0, 60, 0], margin: 20, side: 'bottom', anchor: [90, 0] }, + expected: [[0, 20], [90, 20]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + }); + + QUnit.module('edge cases', function() { + + QUnit.test('boundaries and options', function(assert) { + + const cases = [ + { + name: 'targetInSourceBBox forces the U-shaped detour', + source: { rect: [0, 0, 100, 60], margin: 10, side: 'left', anchor: [0, 30] }, + target: { rect: [-200, 0, 80, 40], margin: 10, side: 'left', anchor: [-200, 20] }, + opt: { targetInSourceBBox: true }, + expected: [[-210, 30], [-210, 20]] + }, + { + name: 'the same geometry without the flag routes around instead', + source: { rect: [0, 0, 100, 60], margin: 10, side: 'left', anchor: [0, 30] }, + target: { rect: [-200, 0, 80, 40], margin: 10, side: 'left', anchor: [-200, 20] }, + expected: [[-60, 30], [-60, 50], [-210, 50], [-210, 20]] + }, + { + name: 'bboxes sharing a center, different widths', + source: { rect: [0, 0, 100, 40], margin: 10, side: 'top', anchor: [50, 0] }, + target: { rect: [20, 200, 60, 40], margin: 10, side: 'bottom', anchor: [50, 240] }, + expected: [[50, -10], [110, -10], [110, 250], [50, 250]] + }, + { + name: 'equally far above and below', + source: { rect: [0, 0, 40, 40], margin: 10, side: 'left', anchor: [0, 20] }, + target: { rect: [200, 0, 40, 40], margin: 10, side: 'right', anchor: [240, 20] }, + expected: [[-10, 20], [-10, 50], [250, 50], [250, 20]] + }, + { + name: 'equally far left and right', + source: { rect: [0, 0, 40, 40], margin: 10, side: 'top', anchor: [20, 0] }, + target: { rect: [0, 200, 40, 40], margin: 10, side: 'bottom', anchor: [20, 240] }, + expected: [[20, -10], [50, -10], [50, 250], [20, 250]] + }, + { + name: 'overlapping elements', + source: { rect: [0, 0, 100, 100], margin: 10, side: 'right', anchor: [100, 50] }, + target: { rect: [50, 50, 100, 100], margin: 10, side: 'left', anchor: [50, 100] }, + expected: [[110, 50], [110, 75], [40, 75], [40, 100]] + }, + { + name: 'touching elements', + source: { rect: [0, 0, 100, 100], margin: 0, side: 'right', anchor: [100, 50] }, + target: { rect: [100, 0, 100, 100], margin: 0, side: 'left', anchor: [100, 50] }, + expected: [[100, 50], [100, 50], [100, 50], [100, 50]] + }, + { + name: 'zero margins', + source: { rect: [0, 0, 40, 40], margin: 0, side: 'right', anchor: [40, 20] }, + target: { rect: [100, 100, 40, 40], margin: 0, side: 'top', anchor: [120, 100] }, + expected: [[120, 20]] + }, + { + name: 'minPathMargin below both margins', + source: { rect: [0, 0, 40, 40], margin: 20, side: 'right', anchor: [40, 20] }, + target: { rect: [60, 30, 40, 40], margin: 20, side: 'left', anchor: [60, 50] }, + opt: { minPathMargin: 0 }, + expected: [[50, 20], [50, 35], [50, 35], [50, 50]] + }, + { + name: 'the same geometry without minPathMargin', + source: { rect: [0, 0, 40, 40], margin: 20, side: 'right', anchor: [40, 20] }, + target: { rect: [60, 30, 40, 40], margin: 20, side: 'left', anchor: [60, 50] }, + expected: [[60, 20], [60, 35], [40, 35], [40, 50]] + }, + { + name: 'coincident anchors', + source: { rect: [0, 0, 40, 40], margin: 10, side: 'right', anchor: [20, 20] }, + target: { rect: [0, 0, 40, 40], margin: 10, side: 'left', anchor: [20, 20] }, + expected: [[50, 20], [50, 20], [-10, 20], [-10, 20]] + }, + { + name: 'both ends leaving the same side, level with each other', + source: { rect: [0, 0, 40, 40], margin: 10, side: 'top', anchor: [20, 0] }, + target: { rect: [100, 0, 40, 40], margin: 10, side: 'top', anchor: [120, 0] }, + expected: [[20, -10], [120, -10]] + }, + { + name: 'both ends leaving the same side, aligned on one column', + source: { rect: [0, 0, 40, 40], margin: 10, side: 'left', anchor: [0, 20] }, + target: { rect: [0, 100, 40, 40], margin: 10, side: 'left', anchor: [0, 120] }, + expected: [[-10, 20], [-10, 120]] + }, + { + name: 'a zero-size source', + source: { rect: [50, 50, 0, 0], margin: 10, side: 'right', anchor: [50, 50] }, + target: { rect: [200, 0, 40, 40], margin: 10, side: 'left', anchor: [200, 20] }, + expected: [[125, 50], [125, 20]] + }, + { + name: 'the target end point inside the source bbox', + source: { rect: [0, 0, 200, 200], margin: 10, side: 'bottom', anchor: [100, 200] }, + target: { rect: [80, 80, 40, 40], margin: 10, side: 'right', anchor: [120, 100] }, + expected: [[100, 210], [210, 210], [210, 100]] + }, + { + name: 'the horizontal route, x1 adjustment when the elements intersect', + source: { rect: [0, 0, 0, 60], margin: 20, side: 'right', anchor: [0, 0] }, + target: { rect: [-20, 20, 40, 20], margin: 10, side: 'left', anchor: [-20, 40] }, + opt: { minPathMargin: 5, targetInSourceBBox: true }, + expected: [[20, 0], [20, -20], [-30, -20], [-30, 40]] + }, + { + name: 'the horizontal route, x2 adjustment when the elements intersect', + source: { rect: [0, 0, 60, 0], margin: 0, side: 'left', anchor: [0, 0] }, + target: { rect: [30, -20, 0, 60], margin: 20, side: 'right', anchor: [30, 40] }, + opt: { minPathMargin: 0 }, + expected: [[0, 0], [0, 60], [50, 60], [50, 40]] + }, + { + name: 'the vertical route, y1 adjustment when the elements intersect', + source: { rect: [0, 0, 20, 40], margin: 20, side: 'top', anchor: [10, 0] }, + target: { rect: [40, -50, 20, 40], margin: 10, side: 'bottom', anchor: [45, -10] }, + opt: { minPathMargin: 10, targetInSourceBBox: true }, + expected: [[10, -20], [-20, -20], [-20, 60], [45, 60]] + }, + { + name: 'the vertical route, y2 adjustment when the elements intersect', + source: { rect: [0, 0, 0, 60], margin: 10, side: 'top', anchor: [0, 30] }, + target: { rect: [-20, 20, 40, 0], margin: 0, side: 'bottom', anchor: [-20, 20] }, + opt: { minPathMargin: 30, targetInSourceBBox: true }, + expected: [[0, -10], [-20, -10], [-20, 20], [-20, 20]] + } + ]; + + cases.forEach(function(testCase, index) { + assert.deepEqual(route(testCase), testCase.expected, describe(testCase, index)); + }); + }); + }); + + QUnit.module('properties', function() { + + const SIDES = ['left', 'right', 'top', 'bottom']; + + // A deterministic sweep, on a coarse grid so that equal coordinates - + // where most of the algorithm's decisions are decided - come up often. + function sweep(count) { + let state = 0x1234abcd; + function random() { + state |= 0; + state = state + 0x6D2B79F5 | 0; + let t = Math.imul(state ^ state >>> 15, 1 | state); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } + function pick(values) { + return values[Math.floor(random() * values.length)]; + } + + const SIZES = [0, 20, 40, 60]; + const OFFSETS = [-80, -50, -20, 0, 20, 50, 80]; + const MARGINS = [0, 10, 20]; + const cases = []; + + // The router runs the route to an anchor on the side it leaves + // through, so put the anchors there rather than anywhere. + function end(rect) { + const side = pick(SIDES); + const x0 = rect[0], y0 = rect[1], x1 = x0 + rect[2], y1 = y0 + rect[3]; + const t = pick([0, 0.5, 1]); + let anchor; + switch (side) { + case 'left': anchor = [x0, y0 + t * rect[3]]; break; + case 'right': anchor = [x1, y0 + t * rect[3]]; break; + case 'top': anchor = [x0 + t * rect[2], y0]; break; + case 'bottom': anchor = [x0 + t * rect[2], y1]; break; + } + return { rect: rect, margin: pick(MARGINS), side: side, anchor: anchor }; + } + + for (let i = 0; i < count; i++) { + cases.push({ + source: end([0, 0, pick(SIZES), pick(SIZES)]), + target: end([pick(OFFSETS), pick(OFFSETS), pick(SIZES), pick(SIZES)]), + opt: { minPathMargin: pick([undefined, 0, 5, 30]), targetInSourceBBox: random() < 0.2 } + }); + } + + return cases; + } + + // Where the route actually starts and ends - `margin` outside the bbox, + // on the side it goes through. The returned points do not have to + // include these two (the router treats them as vertices between the + // link's own ends), but the route through them has to stay orthogonal. + function outsidePoint({ rect, margin, side, anchor }) { + const x0 = rect[0], y0 = rect[1], x1 = x0 + rect[2], y1 = y0 + rect[3]; + switch (side) { + case 'left': return [x0 - margin, anchor[1]]; + case 'right': return [x1 + margin, anchor[1]]; + case 'top': return [anchor[0], y0 - margin]; + case 'bottom': return [anchor[0], y1 + margin]; + } + } + + function report(assert, failures, total, message) { + assert.equal( + failures.length, + 0, + failures.length + ? `${message} - ${failures.length} of ${total} failed, first: ${failures[0]}` + : `${message} (${total} cases)` + ); + } + + QUnit.test('every side combination returns a route', function(assert) { + + SIDES.forEach(function(sourceSide) { + SIDES.forEach(function(targetSide) { + const points = route({ + source: { rect: [0, 0, 100, 60], margin: 10, side: sourceSide, anchor: [50, 30] }, + target: { rect: [300, 200, 80, 40], margin: 20, side: targetSide, anchor: [340, 220] } + }); + assert.ok(points.length > 0, `${sourceSide} => ${targetSide} returns at least one point`); + }); + }); + }); + + QUnit.test('an unknown side returns nothing', function(assert) { + + const points = joint.alg.rightAnglePath( + makeEnd({ rect: [0, 0, 100, 60], margin: 10, side: 'sideways', anchor: [50, 30] }), + makeEnd({ rect: [300, 200, 80, 40], margin: 20, side: 'left', anchor: [300, 220] }) + ); + + assert.strictEqual(points, undefined); + }); + + QUnit.test('the whole route is orthogonal', function(assert) { + + const cases = sweep(3000); + const failures = []; + + cases.forEach(function(testCase, index) { + // The offset points bracket the returned ones: every step of + // that polyline has to run along one axis only. + const polyline = [outsidePoint(testCase.source)] + .concat(route(testCase)) + .concat([outsidePoint(testCase.target)]); + + for (let i = 1; i < polyline.length; i++) { + const from = polyline[i - 1]; + const to = polyline[i]; + if (from[0] !== to[0] && from[1] !== to[1]) { + failures.push(`${describe(testCase, index)} - step ${i} from [${from}] to [${to}]`); + return; + } + } + }); + + report(assert, failures, cases.length, 'every step runs along one axis'); + }); + + QUnit.test('every coordinate is a finite number', function(assert) { + + const cases = sweep(3000); + const failures = []; + + cases.forEach(function(testCase, index) { + const points = route(testCase); + if (points.length === 0) { + failures.push(`${describe(testCase, index)} - empty route`); + return; + } + const bad = points.some(function(point) { + return !Number.isFinite(point[0]) || !Number.isFinite(point[1]); + }); + if (bad) failures.push(`${describe(testCase, index)} - ${JSON.stringify(points)}`); + }); + + report(assert, failures, cases.length, 'all coordinates finite'); + }); + + QUnit.test('a route never needs more than five points', function(assert) { + + const cases = sweep(3000); + const failures = []; + + cases.forEach(function(testCase, index) { + const points = route(testCase); + if (points.length > 5) failures.push(`${describe(testCase, index)} - ${points.length} points`); + }); + + report(assert, failures, cases.length, 'at most five points'); + }); + + QUnit.test('scaling the input scales the route', function(assert) { + + // The algorithm has no lengths of its own: multiply every + // coordinate, size and margin by the same factor and the route it + // picks has to come out multiplied by that factor too. Anything + // that broke this would be an absolute constant hiding in a + // comparison. + const SCALE = 3; + const cases = sweep(1000); + const failures = []; + + function scaleEnd(end) { + return { + rect: end.rect.map(function(n) { return n * SCALE; }), + margin: end.margin * SCALE, + side: end.side, + anchor: end.anchor.map(function(n) { return n * SCALE; }) + }; + } + + cases.forEach(function(testCase, index) { + const expected = route(testCase).map(function(point) { + return [point[0] * SCALE, point[1] * SCALE]; + }); + const scaled = route({ + source: scaleEnd(testCase.source), + target: scaleEnd(testCase.target), + opt: { + minPathMargin: testCase.opt.minPathMargin === undefined + ? undefined + : testCase.opt.minPathMargin * SCALE, + targetInSourceBBox: testCase.opt.targetInSourceBBox + } + }); + + if (JSON.stringify(scaled) !== JSON.stringify(expected)) { + failures.push(`${describe(testCase, index)} - got ${JSON.stringify(scaled)}, ` + + `expected ${JSON.stringify(expected)}`); + } + }); + + report(assert, failures, cases.length, 'the route scales with the input'); + }); + }); +}); From 67fd5cf5f8aa864c226221811bbe4dd29f893485 Mon Sep 17 00:00:00 2001 From: zbynekstara Date: Thu, 20 Aug 2026 19:08:59 +0200 Subject: [PATCH 6/6] add a real changeset --- .changeset/fair-wasps-cheer.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.changeset/fair-wasps-cheer.md b/.changeset/fair-wasps-cheer.md index f0184f387e..3d20c3911b 100644 --- a/.changeset/fair-wasps-cheer.md +++ b/.changeset/fair-wasps-cheer.md @@ -1,6 +1,5 @@ --- - +'@joint/core': patch --- - - \ No newline at end of file +alg.rightAnglePath: refactor to consolidate duplicate code into helpers