@rxflow/manhattan
Version:
Manhattan routing algorithm for ReactFlow - generates orthogonal paths with obstacle avoidance
418 lines (381 loc) • 20.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.getManHattanPath = getManHattanPath;
var _react = require("@xyflow/react");
var _geometry = require("./geometry");
var _obstacle = require("./obstacle");
var _options = require("./options");
var _pathfinder = require("./pathfinder");
var _svg = require("./svg");
var _utils = require("./utils");
/**
* Parameters for getManHattanPath function
*/
/**
* Generate Manhattan-routed path for ReactFlow edges
*
* @param params - Path generation parameters
* @returns SVG path string that can be used with ReactFlow's BaseEdge
*
* @example
* ```typescript
* const path = getManHattanPath({
* sourceNodeId: 'node1',
* targetNodeId: 'node2',
* sourcePosition: { x: 100, y: 100 },
* targetPosition: { x: 300, y: 300 },
* nodeLookup: nodes,
* options: {
* step: 10,
* startDirections: ['bottom'],
* endDirections: ['top']
* }
* })
* ```
*/
function getManHattanPath(params) {
const {
sourceNodeId,
targetNodeId,
sourcePosition,
targetPosition,
nodeLookup,
sourceX,
sourceY,
targetX,
targetY,
options: userOptions = {}
} = params;
// Resolve options and add position information
const options = (0, _options.resolveOptions)({
...userOptions,
sourcePosition,
targetPosition
});
// Direction control is automatically handled by getRectPoints:
// - When anchor is on an edge, only outward directions are allowed (via isDirectionOutward)
// - For sourcePosition="right": anchor on right edge -> only extends right
// - For targetPosition="left": anchor on left edge -> path approaches from right (outward from left)
// This ensures paths follow the sourcePosition and targetPosition constraints
// Get source and target nodes
const sourceNode = nodeLookup.get(sourceNodeId);
const targetNode = nodeLookup.get(targetNodeId);
if (!sourceNode || !targetNode) {
// Fallback to simple straight line if nodes not found
console.warn('Source or target node not found in nodeLookup');
const start = new _geometry.Point(sourceX, sourceY);
const end = new _geometry.Point(targetX, targetY);
return (0, _svg.pointsToPath)([start, end], options.precision);
}
// Get node dimensions using ReactFlow's priority logic
const sourceDimensions = (0, _utils.getNodeDimensions)(sourceNode);
const targetDimensions = (0, _utils.getNodeDimensions)(targetNode);
// Get absolute positions from internals
const sourcePos = (0, _utils.getNodePosition)(sourceNode);
const targetPos = (0, _utils.getNodePosition)(targetNode);
// Calculate bounding boxes
const sourceBBox = new _geometry.Rectangle(sourcePos.x, sourcePos.y, sourceDimensions.width, sourceDimensions.height);
const targetBBox = new _geometry.Rectangle(targetPos.x, targetPos.y, targetDimensions.width, targetDimensions.height);
// Create anchor points
const sourceAnchor = new _geometry.Point(sourceX, sourceY);
const targetAnchor = new _geometry.Point(targetX, targetY);
// Try ReactFlow's getSmoothStepPath first
const [smoothStepPath] = (0, _react.getSmoothStepPath)({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: options.borderRadius
});
// Parse the smooth step path to extract points
const smoothStepPoints = (0, _svg.parseSVGPath)(smoothStepPath);
// Check if smooth step path intersects with any obstacles
if (smoothStepPoints.length > 0 && !(0, _utils.pathIntersectsObstacles)(smoothStepPoints, nodeLookup)) {
console.log('[getManHattanPath] Using ReactFlow getSmoothStepPath (no obstacles)');
return smoothStepPath;
}
console.log('[getManHattanPath] SmoothStepPath intersects obstacles, using Manhattan routing');
// Build obstacle map with anchor information
const obstacleMap = new _obstacle.ObstacleMap(options).build(nodeLookup, sourceNodeId, targetNodeId, sourceAnchor, targetAnchor);
// Find route
let route = (0, _pathfinder.findRoute)(sourceBBox, targetBBox, sourceAnchor, targetAnchor, obstacleMap, options);
// Fallback to straight line if no route found
if (!route) {
console.warn('Unable to find Manhattan route, using straight line fallback');
route = [sourceAnchor, targetAnchor];
}
console.log('[getManHattanPath] Route from findRoute:', route.map(p => `(${p.x}, ${p.y})`));
console.log('[getManHattanPath] Source anchor:', `(${sourceAnchor.x}, ${sourceAnchor.y})`);
console.log('[getManHattanPath] Target anchor:', `(${targetAnchor.x}, ${targetAnchor.y})`);
// If using smart point generation (sourcePosition/targetPosition specified),
// the route already contains the correct extension points, so skip manual processing
const useSmartPoints = sourcePosition || targetPosition;
if (useSmartPoints) {
console.log('[getManHattanPath] Using smart points, skipping manual extension point processing');
// Add source and target anchors to route
const finalRoute = [sourceAnchor, ...route, targetAnchor];
console.log('[getManHattanPath] Final route:', finalRoute.map(p => `(${p.x}, ${p.y})`));
return (0, _svg.pointsToPath)(finalRoute, options.precision, options.borderRadius);
}
// Remove extension points from route that were added by getRectPoints
// We will add our own with fixed step distance
const step = options.step;
const tolerance = 1;
// Check if first point is an extension point from source
if (route.length > 0) {
const firstPoint = route[0];
const onLeft = Math.abs(sourceAnchor.x - sourceBBox.x) < tolerance;
const onRight = Math.abs(sourceAnchor.x - (sourceBBox.x + sourceBBox.width)) < tolerance;
const onTop = Math.abs(sourceAnchor.y - sourceBBox.y) < tolerance;
const onBottom = Math.abs(sourceAnchor.y - (sourceBBox.y + sourceBBox.height)) < tolerance;
// Check if firstPoint is close to source anchor (indicating it's an extension point)
const distToFirst = sourceAnchor.manhattanDistance(firstPoint);
if (distToFirst < step * 2) {
// This is likely an extension point, remove it
if (onRight && firstPoint.x > sourceAnchor.x || onLeft && firstPoint.x < sourceAnchor.x || onBottom && firstPoint.y > sourceAnchor.y || onTop && firstPoint.y < sourceAnchor.y) {
route.shift();
console.log('[getManHattanPath] Removed extension point from route start');
}
}
}
// Check if last point is an extension point from target
if (route.length > 0) {
const lastPoint = route[route.length - 1];
const onLeft = Math.abs(targetAnchor.x - targetBBox.x) < tolerance;
const onRight = Math.abs(targetAnchor.x - (targetBBox.x + targetBBox.width)) < tolerance;
const onTop = Math.abs(targetAnchor.y - targetBBox.y) < tolerance;
const onBottom = Math.abs(targetAnchor.y - (targetBBox.y + targetBBox.height)) < tolerance;
// Check if lastPoint is close to target anchor (indicating it's an extension point)
const distToLast = targetAnchor.manhattanDistance(lastPoint);
if (distToLast < step * 2) {
// This is likely an extension point, remove it
if (onLeft && lastPoint.x < targetAnchor.x || onRight && lastPoint.x > targetAnchor.x || onTop && lastPoint.y < targetAnchor.y || onBottom && lastPoint.y > targetAnchor.y) {
route.pop();
console.log('[getManHattanPath] Removed extension point from route end');
}
}
}
// Insert extension point at source - always extend away from node edge by fixed distance
if (route.length > 0) {
const extensionDistance = options.extensionDistance;
const firstPoint = route[0];
// Determine which edge the source anchor is on
const onLeft = Math.abs(sourceAnchor.x - sourceBBox.x) < tolerance;
const onRight = Math.abs(sourceAnchor.x - (sourceBBox.x + sourceBBox.width)) < tolerance;
const onTop = Math.abs(sourceAnchor.y - sourceBBox.y) < tolerance;
const onBottom = Math.abs(sourceAnchor.y - (sourceBBox.y + sourceBBox.height)) < tolerance;
// Insert extension point and corner point to ensure orthogonal path
if (onRight) {
// Anchor on right edge - extend right by step + borderRadius
const extendX = sourceAnchor.x + extensionDistance;
const extensionPoint = new _geometry.Point(extendX, sourceAnchor.y);
// Check if we need a corner point
if (Math.abs(extensionPoint.y - firstPoint.y) > tolerance) {
route.unshift(new _geometry.Point(extendX, firstPoint.y)); // Corner point
}
route.unshift(extensionPoint); // Extension point (fixed distance)
console.log('[getManHattanPath] Inserted source extension (right):', `(${extendX}, ${sourceAnchor.y})`);
} else if (onLeft) {
// Anchor on left edge - extend left by step + borderRadius
const extendX = sourceAnchor.x - extensionDistance;
const extensionPoint = new _geometry.Point(extendX, sourceAnchor.y);
if (Math.abs(extensionPoint.y - firstPoint.y) > tolerance) {
route.unshift(new _geometry.Point(extendX, firstPoint.y));
}
route.unshift(extensionPoint);
console.log('[getManHattanPath] Inserted source extension (left):', `(${extendX}, ${sourceAnchor.y})`);
} else if (onBottom) {
// Anchor on bottom edge - extend down by step + borderRadius
const extendY = sourceAnchor.y + extensionDistance;
const extensionPoint = new _geometry.Point(sourceAnchor.x, extendY);
if (Math.abs(extensionPoint.x - firstPoint.x) > tolerance) {
route.unshift(new _geometry.Point(firstPoint.x, extendY));
}
route.unshift(extensionPoint);
console.log('[getManHattanPath] Inserted source extension (down):', `(${sourceAnchor.x}, ${extendY})`);
} else if (onTop) {
// Anchor on top edge - extend up by step + borderRadius
const extendY = sourceAnchor.y - extensionDistance;
const extensionPoint = new _geometry.Point(sourceAnchor.x, extendY);
if (Math.abs(extensionPoint.x - firstPoint.x) > tolerance) {
route.unshift(new _geometry.Point(firstPoint.x, extendY));
}
route.unshift(extensionPoint);
console.log('[getManHattanPath] Inserted source extension (up):', `(${sourceAnchor.x}, ${extendY})`);
}
}
// Remove redundant points after source extension
// If the first route point has the same x or y coordinate as the source anchor, it's redundant
if (route.length > 2) {
const firstRoutePoint = route[0]; // Extension point
const secondRoutePoint = route[1]; // Corner point (if exists)
const thirdRoutePoint = route[2]; // Original A* point
// Check if the third point (original A* point) is redundant
// It's redundant if it's on the same line as the corner point and can be skipped
const sameX = Math.abs(thirdRoutePoint.x - sourceAnchor.x) < tolerance;
const sameY = Math.abs(thirdRoutePoint.y - sourceAnchor.y) < tolerance;
if (sameX || sameY) {
// The third point is aligned with the source anchor, likely redundant
// Check if we can skip it by connecting corner point directly to the next point
if (route.length > 3) {
const fourthPoint = route[3];
// If corner point and fourth point form a straight line, remove the third point
const cornerToThird = Math.abs(secondRoutePoint.x - thirdRoutePoint.x) < tolerance || Math.abs(secondRoutePoint.y - thirdRoutePoint.y) < tolerance;
const thirdToFourth = Math.abs(thirdRoutePoint.x - fourthPoint.x) < tolerance || Math.abs(thirdRoutePoint.y - fourthPoint.y) < tolerance;
if (cornerToThird && thirdToFourth) {
console.log('[getManHattanPath] Removing redundant point:', `(${thirdRoutePoint.x}, ${thirdRoutePoint.y})`);
route.splice(2, 1); // Remove the third point
}
}
}
}
// Optimize zigzag patterns BEFORE inserting target extension
// Check for patterns like: (1360, 16) -> (815, 16) -> (815, -134)
// where the middle segment goes to target edge and then moves along it
// Use target bbox with padding (same as ObstacleMap)
const targetBBoxWithPadding = targetBBox.moveAndExpand(options.paddingBox);
console.log('[getManHattanPath] Route before zigzag check:', route.map(p => `(${p.x}, ${p.y})`));
console.log('[getManHattanPath] Target BBox with padding:', `x=${targetBBoxWithPadding.x}, y=${targetBBoxWithPadding.y}`);
if (route.length >= 3) {
let i = 0;
while (i < route.length - 2) {
const p1 = route[i];
const p2 = route[i + 1];
const p3 = route[i + 2];
// Check if p2 is on the target bbox edge (with padding)
const p2OnTargetLeftEdge = Math.abs(p2.x - targetBBoxWithPadding.x) < tolerance;
const p2OnTargetRightEdge = Math.abs(p2.x - (targetBBoxWithPadding.x + targetBBoxWithPadding.width)) < tolerance;
const p2OnTargetTopEdge = Math.abs(p2.y - targetBBoxWithPadding.y) < tolerance;
const p2OnTargetBottomEdge = Math.abs(p2.y - (targetBBoxWithPadding.y + targetBBoxWithPadding.height)) < tolerance;
const p2OnTargetEdge = p2OnTargetLeftEdge || p2OnTargetRightEdge || p2OnTargetTopEdge || p2OnTargetBottomEdge;
console.log(`[getManHattanPath] Checking i=${i}: p2=(${p2.x}, ${p2.y}), onEdge=${p2OnTargetEdge}`);
if (p2OnTargetEdge) {
// Check if p1 -> p2 -> p3 forms a zigzag
const p1ToP2Horizontal = Math.abs(p1.y - p2.y) < tolerance;
const p2ToP3Vertical = Math.abs(p2.x - p3.x) < tolerance;
const p1ToP2Vertical = Math.abs(p1.x - p2.x) < tolerance;
const p2ToP3Horizontal = Math.abs(p2.y - p3.y) < tolerance;
console.log(`[getManHattanPath] Zigzag pattern: H->V=${p1ToP2Horizontal && p2ToP3Vertical}, V->H=${p1ToP2Vertical && p2ToP3Horizontal}`);
if (p1ToP2Horizontal && p2ToP3Vertical || p1ToP2Vertical && p2ToP3Horizontal) {
// We have a zigzag at target edge, remove p2 and p3
console.log('[getManHattanPath] Removing zigzag at target edge:', `(${p2.x}, ${p2.y})`, `and (${p3.x}, ${p3.y})`);
route.splice(i + 1, 2); // Remove p2 and p3
continue;
}
}
i++;
}
}
// Insert extension point at target - always extend away from node edge by fixed distance
if (route.length > 0) {
const extensionDistance = options.extensionDistance;
const lastPoint = route[route.length - 1];
// Determine which edge the target anchor is on
const onLeft = Math.abs(targetAnchor.x - targetBBox.x) < tolerance;
const onRight = Math.abs(targetAnchor.x - (targetBBox.x + targetBBox.width)) < tolerance;
const onTop = Math.abs(targetAnchor.y - targetBBox.y) < tolerance;
const onBottom = Math.abs(targetAnchor.y - (targetBBox.y + targetBBox.height)) < tolerance;
// Insert extension point and corner point to ensure orthogonal path
if (onLeft) {
// Anchor on left edge - extend left by step + borderRadius
const extendX = targetAnchor.x - extensionDistance;
const extensionPoint = new _geometry.Point(extendX, targetAnchor.y);
if (Math.abs(extensionPoint.y - lastPoint.y) > tolerance) {
route.push(new _geometry.Point(extendX, lastPoint.y)); // Corner point
}
route.push(extensionPoint); // Extension point (fixed distance)
console.log('[getManHattanPath] Inserted target extension (left):', `(${extendX}, ${targetAnchor.y})`);
} else if (onRight) {
// Anchor on right edge - extend right by step + borderRadius
const extendX = targetAnchor.x + extensionDistance;
const extensionPoint = new _geometry.Point(extendX, targetAnchor.y);
if (Math.abs(extensionPoint.y - lastPoint.y) > tolerance) {
route.push(new _geometry.Point(extendX, lastPoint.y));
}
route.push(extensionPoint);
console.log('[getManHattanPath] Inserted target extension (right):', `(${extendX}, ${targetAnchor.y})`);
} else if (onTop) {
// Anchor on top edge - extend up by step + borderRadius
const extendY = targetAnchor.y - extensionDistance;
const extensionPoint = new _geometry.Point(targetAnchor.x, extendY);
if (Math.abs(extensionPoint.x - lastPoint.x) > tolerance) {
route.push(new _geometry.Point(lastPoint.x, extendY));
}
route.push(extensionPoint);
console.log('[getManHattanPath] Inserted target extension (up):', `(${targetAnchor.x}, ${extendY})`);
} else if (onBottom) {
// Anchor on bottom edge - extend down by step + borderRadius
const extendY = targetAnchor.y + extensionDistance;
const extensionPoint = new _geometry.Point(targetAnchor.x, extendY);
if (Math.abs(extensionPoint.x - lastPoint.x) > tolerance) {
route.push(new _geometry.Point(lastPoint.x, extendY));
}
route.push(extensionPoint);
console.log('[getManHattanPath] Inserted target extension (down):', `(${targetAnchor.x}, ${extendY})`);
}
}
// Remove redundant points before target extension
// Similar logic for target side
if (route.length > 2) {
const lastIdx = route.length - 1;
const lastRoutePoint = route[lastIdx]; // Extension point
const secondLastPoint = route[lastIdx - 1]; // Corner point (if exists)
const thirdLastPoint = route[lastIdx - 2]; // Original A* point
// Check if the third-to-last point is redundant
const sameX = Math.abs(thirdLastPoint.x - targetAnchor.x) < tolerance;
const sameY = Math.abs(thirdLastPoint.y - targetAnchor.y) < tolerance;
if (sameX || sameY) {
if (route.length > 3) {
const fourthLastPoint = route[lastIdx - 3];
const fourthToThird = Math.abs(fourthLastPoint.x - thirdLastPoint.x) < tolerance || Math.abs(fourthLastPoint.y - thirdLastPoint.y) < tolerance;
const thirdToSecond = Math.abs(thirdLastPoint.x - secondLastPoint.x) < tolerance || Math.abs(thirdLastPoint.y - secondLastPoint.y) < tolerance;
if (fourthToThird && thirdToSecond) {
console.log('[getManHattanPath] Removing redundant point:', `(${thirdLastPoint.x}, ${thirdLastPoint.y})`);
route.splice(lastIdx - 2, 1); // Remove the third-to-last point
}
}
}
}
// Additional optimization: Remove unnecessary zigzag patterns near target
// Check for patterns like: (1360, 16) -> (815, 16) -> (815, -134) -> (800, -134)
// where (815, 16) and (815, -134) form a zigzag at the target edge
let i = 0;
while (i < route.length - 2) {
const p1 = route[i];
const p2 = route[i + 1];
const p3 = route[i + 2];
// Check if p2 is on the target bbox edge
const p2OnTargetLeftEdge = Math.abs(p2.x - targetBBox.x) < tolerance;
const p2OnTargetRightEdge = Math.abs(p2.x - (targetBBox.x + targetBBox.width)) < tolerance;
const p2OnTargetTopEdge = Math.abs(p2.y - targetBBox.y) < tolerance;
const p2OnTargetBottomEdge = Math.abs(p2.y - (targetBBox.y + targetBBox.height)) < tolerance;
const p2OnTargetEdge = p2OnTargetLeftEdge || p2OnTargetRightEdge || p2OnTargetTopEdge || p2OnTargetBottomEdge;
if (p2OnTargetEdge) {
// Check if p1 -> p2 -> p3 forms a zigzag
const p1ToP2Horizontal = Math.abs(p1.y - p2.y) < tolerance;
const p2ToP3Vertical = Math.abs(p2.x - p3.x) < tolerance;
if (p1ToP2Horizontal && p2ToP3Vertical && i < route.length - 3) {
// We have horizontal -> vertical at target edge
// Check if we can skip p2 and p3
const p4 = route[i + 3];
const p3ToP4Horizontal = Math.abs(p3.y - p4.y) < tolerance;
if (p3ToP4Horizontal) {
// Pattern: horizontal -> vertical -> horizontal (zigzag)
console.log('[getManHattanPath] Removing zigzag at target edge:', `(${p2.x}, ${p2.y})`, `and (${p3.x}, ${p3.y})`);
route.splice(i + 1, 2); // Remove p2 and p3
continue;
}
}
}
i++;
}
// Add source and target anchors to route
const finalRoute = [sourceAnchor, ...route, targetAnchor];
console.log('[getManHattanPath] Final route:', finalRoute.map(p => `(${p.x}, ${p.y})`));
// Convert to SVG path string
return (0, _svg.pointsToPath)(finalRoute, options.precision, options.borderRadius);
}