UNPKG

diagram-js

Version:

A toolbox for displaying and modifying diagrams on the web

754 lines (603 loc) 18.1 kB
import { assign, find, isArray, without } from 'min-dash'; import { getOrientation, getMid } from './LayoutUtil'; import { pointInRect, pointDistance, pointsAligned, pointsOnLine } from '../util/Geometry'; /** * @typedef {import('../util/Types').Point} Point * @typedef {import('../util/Types').Rect} Rect */ var MIN_SEGMENT_LENGTH = 20, POINT_ORIENTATION_PADDING = 5; var round = Math.round; var INTERSECTION_THRESHOLD = 20, ORIENTATION_THRESHOLD = { 'h:h': 20, 'v:v': 20, 'h:v': -10, 'v:h': -10 }; function needsTurn(orientation, startDirection) { return !{ t: /top/, r: /right/, b: /bottom/, l: /left/, h: /./, v: /./ }[startDirection].test(orientation); } function canLayoutStraight(direction, targetOrientation) { return { t: /top/, r: /right/, b: /bottom/, l: /left/, h: /left|right/, v: /top|bottom/ }[direction].test(targetOrientation); } function getSegmentBendpoints(a, b, directions) { var orientation = getOrientation(b, a, POINT_ORIENTATION_PADDING); var startDirection = directions.split(':')[0]; var xmid = round((b.x - a.x) / 2 + a.x), ymid = round((b.y - a.y) / 2 + a.y); var segmentEnd, segmentDirections; var layoutStraight = canLayoutStraight(startDirection, orientation), layoutHorizontal = /h|r|l/.test(startDirection), layoutTurn = false; var turnNextDirections = false; if (layoutStraight) { segmentEnd = layoutHorizontal ? { x: xmid, y: a.y } : { x: a.x, y: ymid }; segmentDirections = layoutHorizontal ? 'h:h' : 'v:v'; } else { layoutTurn = needsTurn(orientation, startDirection); segmentDirections = layoutHorizontal ? 'h:v' : 'v:h'; if (layoutTurn) { if (layoutHorizontal) { turnNextDirections = ymid === a.y; segmentEnd = { x: a.x + MIN_SEGMENT_LENGTH * (/l/.test(startDirection) ? -1 : 1), y: turnNextDirections ? ymid + MIN_SEGMENT_LENGTH : ymid }; } else { turnNextDirections = xmid === a.x; segmentEnd = { x: turnNextDirections ? xmid + MIN_SEGMENT_LENGTH : xmid, y: a.y + MIN_SEGMENT_LENGTH * (/t/.test(startDirection) ? -1 : 1) }; } } else { segmentEnd = { x: xmid, y: ymid }; } } return { waypoints: getBendpoints(a, segmentEnd, segmentDirections).concat(segmentEnd), directions: segmentDirections, turnNextDirections: turnNextDirections }; } function getStartSegment(a, b, directions) { return getSegmentBendpoints(a, b, directions); } function getEndSegment(a, b, directions) { var invertedSegment = getSegmentBendpoints(b, a, invertDirections(directions)); return { waypoints: invertedSegment.waypoints.slice().reverse(), directions: invertDirections(invertedSegment.directions), turnNextDirections: invertedSegment.turnNextDirections }; } function getMidSegment(startSegment, endSegment) { var startDirection = startSegment.directions.split(':')[1], endDirection = endSegment.directions.split(':')[0]; if (startSegment.turnNextDirections) { startDirection = startDirection == 'h' ? 'v' : 'h'; } if (endSegment.turnNextDirections) { endDirection = endDirection == 'h' ? 'v' : 'h'; } var directions = startDirection + ':' + endDirection; var bendpoints = getBendpoints( startSegment.waypoints[startSegment.waypoints.length - 1], endSegment.waypoints[0], directions ); return { waypoints: bendpoints, directions: directions }; } function invertDirections(directions) { return directions.split(':').reverse().join(':'); } /** * Handle simple layouts with maximum two bendpoints. */ function getSimpleBendpoints(a, b, directions) { var xmid = round((b.x - a.x) / 2 + a.x), ymid = round((b.y - a.y) / 2 + a.y); // one point, right or left from a if (directions === 'h:v') { return [ { x: b.x, y: a.y } ]; } // one point, above or below a if (directions === 'v:h') { return [ { x: a.x, y: b.y } ]; } // vertical segment between a and b if (directions === 'h:h') { return [ { x: xmid, y: a.y }, { x: xmid, y: b.y } ]; } // horizontal segment between a and b if (directions === 'v:v') { return [ { x: a.x, y: ymid }, { x: b.x, y: ymid } ]; } throw new Error('invalid directions: can only handle varians of [hv]:[hv]'); } /** * Returns the mid points for a manhattan connection between two points. * * @example h:h (horizontal:horizontal) * * [a]----[x] * | * [x]----[b] * * @example h:v (horizontal:vertical) * * [a]----[x] * | * [b] * * @example h:r (horizontal:right) * * [a]----[x] * | * [b]-[x] * * @param {Point} a * @param {Point} b * @param {string} directions * * @return {Point[]} */ function getBendpoints(a, b, directions) { directions = directions || 'h:h'; if (!isValidDirections(directions)) { throw new Error( 'unknown directions: <' + directions + '>: ' + 'must be specified as <start>:<end> ' + 'with start/end in { h,v,t,r,b,l }' ); } // compute explicit directions, involving trbl dockings // using a three segmented layouting algorithm if (isExplicitDirections(directions)) { var startSegment = getStartSegment(a, b, directions), endSegment = getEndSegment(a, b, directions), midSegment = getMidSegment(startSegment, endSegment); return [].concat( startSegment.waypoints, midSegment.waypoints, endSegment.waypoints ); } // handle simple [hv]:[hv] cases that can be easily computed return getSimpleBendpoints(a, b, directions); } /** * Create a connection between the two points according * to the manhattan layout (only horizontal and vertical) edges. * * @param {Point} a * @param {Point} b * @param {string} [directions='h:h'] Specifies manhattan directions for each * point as {direction}:{direction}. A direction for a point is either * `h` (horizontal) or `v` (vertical). * * @return {Point[]} */ export function connectPoints(a, b, directions) { var points = getBendpoints(a, b, directions); points.unshift(a); points.push(b); return withoutRedundantPoints(points); } /** * Connect two rectangles using a manhattan layouted connection. * * @param {Rect} source source rectangle * @param {Rect} target target rectangle * @param {Point} [start] source docking * @param {Point} [end] target docking * @param {Object} [hints] * @param {string} [hints.preserveDocking=source] preserve docking on selected side * @param {string[]} [hints.preferredLayouts] * @param {Point|boolean} [hints.connectionStart] whether the start changed * @param {Point|boolean} [hints.connectionEnd] whether the end changed * * @return {Point[]} connection points */ export function connectRectangles(source, target, start, end, hints) { var preferredLayouts = hints && hints.preferredLayouts || []; var preferredLayout = without(preferredLayouts, 'straight')[0] || 'h:h'; var threshold = ORIENTATION_THRESHOLD[preferredLayout] || 0; var orientation = getOrientation(source, target, threshold); var directions = getDirections(orientation, preferredLayout); start = start || getMid(source); end = end || getMid(target); var directionSplit = directions.split(':'); // compute actual docking points for start / end // this ensures we properly layout only parts of the // connection that lies in between the two rectangles var startDocking = getDockingPoint(start, source, directionSplit[0], invertOrientation(orientation)), endDocking = getDockingPoint(end, target, directionSplit[1], orientation); return connectPoints(startDocking, endDocking, directions); } /** * Repair the connection between two rectangles, of which one has been updated. * * @param {Rect} source * @param {Rect} target * @param {Point} [start] * @param {Point} [end] * @param {Point[]} [waypoints] * @param {Object} [hints] * @param {string[]} [hints.preferredLayouts] The list of preferred layouts. * @param {boolean} [hints.connectionStart] * @param {boolean} [hints.connectionEnd] * * @return {Point[]} The waypoints of the repaired connection. */ export function repairConnection(source, target, start, end, waypoints, hints) { if (isArray(start)) { waypoints = start; hints = end; start = getMid(source); end = getMid(target); } hints = assign({ preferredLayouts: [] }, hints); waypoints = waypoints || []; var preferredLayouts = hints.preferredLayouts, preferStraight = preferredLayouts.indexOf('straight') !== -1, repairedWaypoints; // just layout non-existing or simple connections // attempt to render straight lines, if required // attempt to layout a straight line repairedWaypoints = preferStraight && tryLayoutStraight(source, target, start, end, hints); if (repairedWaypoints) { return repairedWaypoints; } // try to layout from end repairedWaypoints = hints.connectionEnd && tryRepairConnectionEnd(target, source, end, waypoints); if (repairedWaypoints) { return repairedWaypoints; } // try to layout from start repairedWaypoints = hints.connectionStart && tryRepairConnectionStart(source, target, start, waypoints); if (repairedWaypoints) { return repairedWaypoints; } // or whether nothing seems to have changed if (!hints.connectionStart && !hints.connectionEnd && waypoints && waypoints.length) { return waypoints; } // simply reconnect if nothing else worked return connectRectangles(source, target, start, end, hints); } function inRange(a, start, end) { return a >= start && a <= end; } function isInRange(axis, a, b) { var size = { x: 'width', y: 'height' }; return inRange(a[axis], b[axis], b[axis] + b[size[axis]]); } /** * Lay out a straight connection. * * @param {Rect} source * @param {Rect} target * @param {Point} start * @param {Point} end * @param {Object} [hints] * @param {string} [hints.preserveDocking] * * @return {Point[]|null} The waypoints or null if layout isn't possible. */ export function tryLayoutStraight(source, target, start, end, hints) { var axis = {}, primaryAxis, orientation; orientation = getOrientation(source, target); // only layout a straight connection if shapes are // horizontally or vertically aligned if (!/^(top|bottom|left|right)$/.test(orientation)) { return null; } if (/top|bottom/.test(orientation)) { primaryAxis = 'x'; } if (/left|right/.test(orientation)) { primaryAxis = 'y'; } if (hints.preserveDocking === 'target') { if (!isInRange(primaryAxis, end, source)) { return null; } axis[primaryAxis] = end[primaryAxis]; return [ { x: axis.x !== undefined ? axis.x : start.x, y: axis.y !== undefined ? axis.y : start.y, original: { x: axis.x !== undefined ? axis.x : start.x, y: axis.y !== undefined ? axis.y : start.y } }, { x: end.x, y: end.y } ]; } else { if (!isInRange(primaryAxis, start, target)) { return null; } axis[primaryAxis] = start[primaryAxis]; return [ { x: start.x, y: start.y }, { x: axis.x !== undefined ? axis.x : end.x, y: axis.y !== undefined ? axis.y : end.y, original: { x: axis.x !== undefined ? axis.x : end.x, y: axis.y !== undefined ? axis.y : end.y } } ]; } } /** * Repair a connection from start. * * @param {Rect} moved * @param {Rect} other * @param {Point} newDocking * @param {Point[]} points originalPoints from moved to other * * @return {Point[]|null} The waypoints of the repaired connection. */ function tryRepairConnectionStart(moved, other, newDocking, points) { return _tryRepairConnectionSide(moved, other, newDocking, points); } /** * Repair a connection from end. * * @param {Rect} moved * @param {Rect} other * @param {Point} newDocking * @param {Point[]} points originalPoints from moved to other * * @return {Point[]|null} The waypoints of the repaired connection. */ function tryRepairConnectionEnd(moved, other, newDocking, points) { var waypoints = points.slice().reverse(); waypoints = _tryRepairConnectionSide(moved, other, newDocking, waypoints); return waypoints ? waypoints.reverse() : null; } /** * Repair a connection from one side that moved. * * @param {Rect} moved * @param {Rect} other * @param {Point} newDocking * @param {Point[]} points originalPoints from moved to other * * @return {Point[]} The waypoints of the repaired connection. */ function _tryRepairConnectionSide(moved, other, newDocking, points) { function needsRelayout(points) { if (points.length < 3) { return true; } if (points.length > 4) { return false; } // relayout if two points overlap // this is most likely due to return !!find(points, function(p, idx) { var q = points[idx - 1]; return q && pointDistance(p, q) < 3; }); } function repairBendpoint(candidate, oldPeer, newPeer) { var alignment = pointsAligned(oldPeer, candidate); switch (alignment) { case 'v': // repair horizontal alignment return { x: newPeer.x, y: candidate.y }; case 'h': // repair vertical alignment return { x: candidate.x, y: newPeer.y }; } return { x: candidate.x, y: candidate. y }; } function removeOverlapping(points, a, b) { var i; for (i = points.length - 2; i !== 0; i--) { // intersects (?) break, remove all bendpoints up to this one and relayout if (pointInRect(points[i], a, INTERSECTION_THRESHOLD) || pointInRect(points[i], b, INTERSECTION_THRESHOLD)) { // return sliced old connection return points.slice(i); } } return points; } // (0) only repair what has layoutable bendpoints // (1) if only one bendpoint and on shape moved onto other shapes axis // (horizontally / vertically), relayout if (needsRelayout(points)) { return null; } var oldDocking = points[0], newPoints = points.slice(), slicedPoints; // (2) repair only last line segment and only if it was layouted before newPoints[0] = newDocking; newPoints[1] = repairBendpoint(newPoints[1], oldDocking, newDocking); // (3) if shape intersects with any bendpoint after repair, // remove all segments up to this bendpoint and repair from there slicedPoints = removeOverlapping(newPoints, moved, other); if (slicedPoints !== newPoints) { newPoints = _tryRepairConnectionSide(moved, other, newDocking, slicedPoints); } // (4) do NOT repair if repaired bendpoints are aligned if (newPoints && pointsAligned(newPoints)) { return null; } return newPoints; } /** * Returns the manhattan directions connecting two rectangles * with the given orientation. * * Will always return the default layout, if it is specific * regarding sides already (trbl). * * @example * * ```javascript * getDirections('top'); // -> 'v:v' * getDirections('intersect'); // -> 't:t' * * getDirections('top-right', 'v:h'); // -> 'v:h' * getDirections('top-right', 'h:h'); // -> 'h:h' * ``` * * @param {string} orientation * @param {string} defaultLayout * * @return {string} */ function getDirections(orientation, defaultLayout) { // don't override specific trbl directions if (isExplicitDirections(defaultLayout)) { return defaultLayout; } switch (orientation) { case 'intersect': return 't:t'; case 'top': case 'bottom': return 'v:v'; case 'left': case 'right': return 'h:h'; // 'top-left' // 'top-right' // 'bottom-left' // 'bottom-right' default: return defaultLayout; } } function isValidDirections(directions) { return directions && /^h|v|t|r|b|l:h|v|t|r|b|l$/.test(directions); } function isExplicitDirections(directions) { return directions && /t|r|b|l/.test(directions); } function invertOrientation(orientation) { return { 'top': 'bottom', 'bottom': 'top', 'left': 'right', 'right': 'left', 'top-left': 'bottom-right', 'bottom-right': 'top-left', 'top-right': 'bottom-left', 'bottom-left': 'top-right', }[orientation]; } function getDockingPoint(point, rectangle, dockingDirection, targetOrientation) { // ensure we end up with a specific docking direction // based on the targetOrientation, if <h|v> is being passed if (dockingDirection === 'h') { dockingDirection = /left/.test(targetOrientation) ? 'l' : 'r'; } if (dockingDirection === 'v') { dockingDirection = /top/.test(targetOrientation) ? 't' : 'b'; } if (dockingDirection === 't') { return { original: point, x: point.x, y: rectangle.y }; } if (dockingDirection === 'r') { return { original: point, x: rectangle.x + rectangle.width, y: point.y }; } if (dockingDirection === 'b') { return { original: point, x: point.x, y: rectangle.y + rectangle.height }; } if (dockingDirection === 'l') { return { original: point, x: rectangle.x, y: point.y }; } throw new Error('unexpected dockingDirection: <' + dockingDirection + '>'); } /** * Return list of waypoints with redundant ones filtered out. * * @example * * Original points: * * [x] ----- [x] ------ [x] * | * [x] ----- [x] - [x] * * Filtered: * * [x] ---------------- [x] * | * [x] ----------- [x] * * @param {Point[]} waypoints * * @return {Point[]} */ export function withoutRedundantPoints(waypoints) { return waypoints.reduce(function(points, p, idx) { var previous = points[points.length - 1], next = waypoints[idx + 1]; if (!pointsOnLine(previous, next, p, 0)) { points.push(p); } return points; }, []); }