trassel
Version:
Graph computing in JavaScript
719 lines (663 loc) • 18.6 kB
JavaScript
/**
* This is taken straight from:
* https://gist.github.com/jose-mdz/4a8894c152383b9d7a870c24a04447e4
* https://medium.com/swlh/routing-orthogonal-diagram-connectors-in-javascript-191dc2c5ff70
* Code has been converted to regular JavaScript.
* A few changes have been made. Most especially if no side for the provided points has been provided the closest sides will be computed.
*
* Usage like so:
* // Define shapes
* const shapeA = {left: 50, top: 50, width: 100, height: 100}
* const shapeB = {left: 200, top: 200, width: 50, height: 100}
*
* // Get the connector path
* const path = OrthogonalConnector.route({
* pointA: {shape: shapeA, side: 'bottom', distance: 0.5},
* pointB: {shape: shapeB, side: 'right', distance: 0.5},
* shapeMargin: 10,
* globalBoundsMargin: 100,
* globalBounds: {left: 0, top: 0, width: 500, height: 500},
* })
*
* // Draw path
* const {x, y} = path.shift()
* moveTo(x, y)
* path.forEach(path => lineTo(path.x, path.y))
*/
/**
* Utility Point creator
* @param x
* @param y
*/
function makePt(x, y) {
return { x, y }
}
/**
* Computes distance between two points
* @param a
* @param b
*/
function distance(a, b) {
return Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2))
}
/**
* Abstracts a Rectangle and adds geometric utilities
*/
class Rectangle {
constructor(left, top, width, height) {
this.left = left
this.top = top
this.width = width
this.height = height
}
static get empty() {
return new Rectangle(0, 0, 0, 0)
}
static fromRect(r) {
return new Rectangle(r.left, r.top, r.width, r.height)
}
static fromLTRB(left, top, right, bottom) {
return new Rectangle(left, top, right - left, bottom - top)
}
contains(p) {
return p.x >= this.left && p.x <= this.right && p.y >= this.top && p.y <= this.bottom
}
inflate(horizontal, vertical) {
return Rectangle.fromLTRB(this.left - horizontal, this.top - vertical, this.right + horizontal, this.bottom + vertical)
}
intersects(rectangle) {
const thisX = this.left
const thisY = this.top
const thisW = this.width
const thisH = this.height
const rectX = rectangle.left
const rectY = rectangle.top
const rectW = rectangle.width
const rectH = rectangle.height
return rectX < thisX + thisW && thisX < rectX + rectW && rectY < thisY + thisH && thisY < rectY + rectH
}
union(r) {
const x = [this.left, this.right, r.left, r.right]
const y = [this.top, this.bottom, r.top, r.bottom]
return Rectangle.fromLTRB(Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y))
}
get center() {
return {
x: this.left + this.width / 2,
y: this.top + this.height / 2
}
}
get right() {
return this.left + this.width
}
get bottom() {
return this.top + this.height
}
get location() {
return makePt(this.left, this.top)
}
get northEast() {
return { x: this.right, y: this.top }
}
get southEast() {
return { x: this.right, y: this.bottom }
}
get southWest() {
return { x: this.left, y: this.bottom }
}
get northWest() {
return { x: this.left, y: this.top }
}
get east() {
return makePt(this.right, this.center.y)
}
get north() {
return makePt(this.center.x, this.top)
}
get south() {
return makePt(this.center.x, this.bottom)
}
get west() {
return makePt(this.left, this.center.y)
}
get size() {
return { width: this.width, height: this.height }
}
}
/**
* Represents a node in a graph, whose data is a Point
*/
class PointNode {
constructor(data) {
this.data = data
this.distance = Number.MAX_SAFE_INTEGER
this.shortestPath = []
this.adjacentNodes = new Map()
}
}
/***
* Represents a Graph of Point nodes
*/
class PointGraph {
constructor() {
this.index = {}
}
add(p) {
const { x, y } = p
const xs = x.toString()
const ys = y.toString()
if (!(xs in this.index)) {
this.index[xs] = {}
}
if (!(ys in this.index[xs])) {
this.index[xs][ys] = new PointNode(p)
}
}
getLowestDistanceNode(unsettledNodes) {
let lowestDistanceNode = null
let lowestDistance = Number.MAX_SAFE_INTEGER
for (const node of unsettledNodes) {
const nodeDistance = node.distance
if (nodeDistance < lowestDistance) {
lowestDistance = nodeDistance
lowestDistanceNode = node
}
}
return lowestDistanceNode
}
inferPathDirection(node) {
if (node.shortestPath.length == 0) {
return null
}
return this.directionOfNodes(node.shortestPath[node.shortestPath.length - 1], node)
}
calculateShortestPathFromSource(graph, source) {
source.distance = 0
const settledNodes = new Set()
const unsettledNodes = new Set()
unsettledNodes.add(source)
while (unsettledNodes.size != 0) {
const currentNode = this.getLowestDistanceNode(unsettledNodes)
unsettledNodes.delete(currentNode)
for (const [adjacentNode, edgeWeight] of currentNode.adjacentNodes) {
if (!settledNodes.has(adjacentNode)) {
this.calculateMinimumDistance(adjacentNode, edgeWeight, currentNode)
unsettledNodes.add(adjacentNode)
}
}
settledNodes.add(currentNode)
}
return graph
}
calculateMinimumDistance(evaluationNode, edgeWeigh, sourceNode) {
const sourceDistance = sourceNode.distance
const comingDirection = this.inferPathDirection(sourceNode)
const goingDirection = this.directionOfNodes(sourceNode, evaluationNode)
const changingDirection = comingDirection && goingDirection && comingDirection != goingDirection
const extraWeigh = changingDirection ? Math.pow(edgeWeigh + 1, 2) : 0
if (sourceDistance + edgeWeigh + extraWeigh < evaluationNode.distance) {
evaluationNode.distance = sourceDistance + edgeWeigh + extraWeigh
const shortestPath = [...sourceNode.shortestPath]
shortestPath.push(sourceNode)
evaluationNode.shortestPath = shortestPath
}
}
directionOf(a, b) {
if (a.x === b.x) {
return "h"
} else if (a.y === b.y) {
return "v"
} else {
return null
}
}
directionOfNodes(a, b) {
return this.directionOf(a.data, b.data)
}
connect(a, b) {
const nodeA = this.get(a)
const nodeB = this.get(b)
if (!nodeA || !nodeB) {
throw new Error("A point was not found")
}
nodeA.adjacentNodes.set(nodeB, distance(a, b))
}
has(p) {
const { x, y } = p
const xs = x.toString()
const ys = y.toString()
return xs in this.index && ys in this.index[xs]
}
get(p) {
const { x, y } = p
const xs = x.toString()
const ys = y.toString()
if (xs in this.index && ys in this.index[xs]) {
return this.index[xs][ys]
}
return null
}
}
/**
* Gets the actual point of the connector based on the distance parameter
* @param p
*/
function computePt(p) {
const b = Rectangle.fromRect(p.shape)
switch (p.side) {
case "bottom":
return makePt(b.left + b.width * p.distance, b.bottom)
case "top":
return makePt(b.left + b.width * p.distance, b.top)
case "left":
return makePt(b.left, b.top + b.height * p.distance)
case "right":
return makePt(b.right, b.top + b.height * p.distance)
}
}
/**
* Extrudes the connector point by margin depending on it's side
* @param cp
* @param margin
*/
function extrudeCp(cp, margin) {
const { x, y } = computePt(cp)
switch (cp.side) {
case "top":
return makePt(x, y - margin)
case "right":
return makePt(x + margin, y)
case "bottom":
return makePt(x, y + margin)
case "left":
return makePt(x - margin, y)
}
}
/**
* Returns flag indicating if the side belongs on a vertical axis
* @param side
*/
function isVerticalSide(side) {
return side == "top" || side == "bottom"
}
/**
* Creates a grid of rectangles from the specified set of rulers, contained on the specified bounds
* @param verticals
* @param horizontals
* @param bounds
*/
function rulersToGrid(verticals, horizontals, bounds) {
const result = new Grid()
verticals.sort((a, b) => a - b)
horizontals.sort((a, b) => a - b)
let lastX = bounds.left
let lastY = bounds.top
let column = 0
let row = 0
for (const y of horizontals) {
for (const x of verticals) {
result.set(row, column++, Rectangle.fromLTRB(lastX, lastY, x, y))
lastX = x
}
// Last cell of the row
result.set(row, column, Rectangle.fromLTRB(lastX, lastY, bounds.right, y))
lastX = bounds.left
lastY = y
column = 0
row++
}
lastX = bounds.left
// Last fow of cells
for (const x of verticals) {
result.set(row, column++, Rectangle.fromLTRB(lastX, lastY, x, bounds.bottom))
lastX = x
}
// Last cell of last row
result.set(row, column, Rectangle.fromLTRB(lastX, lastY, bounds.right, bounds.bottom))
return result
}
/**
* Returns an array without repeated points
* @param points
*/
function reducePoints(points) {
const result = []
const map = new Map()
points.forEach(p => {
const { x, y } = p
const arr = map.get(y) || map.set(y, []).get(y)
if (arr.indexOf(x) < 0) {
arr.push(x)
}
})
for (const [y, xs] of map) {
for (const x of xs) {
result.push(makePt(x, y))
}
}
return result
}
/**
* Returns a set of spots generated from the grid, avoiding colliding spots with specified obstacles
* @param grid
* @param obstacles
*/
function gridToSpots(grid, obstacles) {
const obstacleCollision = p => obstacles.filter(o => o.contains(p)).length > 0
const gridPoints = []
for (const [row, data] of grid.data) {
const firstRow = row == 0
const lastRow = row == grid.rows - 1
for (const [col, r] of data) {
const firstCol = col == 0
const lastCol = col == grid.columns - 1
const nw = firstCol && firstRow
const ne = firstRow && lastCol
const se = lastRow && lastCol
const sw = lastRow && firstCol
if (nw || ne || se || sw) {
gridPoints.push(r.northWest, r.northEast, r.southWest, r.southEast)
} else if (firstRow) {
gridPoints.push(r.northWest, r.north, r.northEast)
} else if (lastRow) {
gridPoints.push(r.southEast, r.south, r.southWest)
} else if (firstCol) {
gridPoints.push(r.northWest, r.west, r.southWest)
} else if (lastCol) {
gridPoints.push(r.northEast, r.east, r.southEast)
} else {
gridPoints.push(r.northWest, r.north, r.northEast, r.east, r.southEast, r.south, r.southWest, r.west, r.center)
}
}
}
// for(const r of grid) {
// gridPoints.push(
// r.northWest, r.north, r.northEast, r.east,
// r.southEast, r.south, r.southWest, r.west, r.center);
// }
// Reduce repeated points and filter out those who touch shapes
return reducePoints(gridPoints).filter(p => !obstacleCollision(p))
}
/**
* Creates a graph connecting the specified points orthogonally
* @param spots
*/
function createGraph(spots) {
const hotXs = []
const hotYs = []
const graph = new PointGraph()
const connections = []
spots.forEach(p => {
const { x, y } = p
if (hotXs.indexOf(x) < 0) {
hotXs.push(x)
}
if (hotYs.indexOf(y) < 0) {
hotYs.push(y)
}
graph.add(p)
})
hotXs.sort((a, b) => a - b)
hotYs.sort((a, b) => a - b)
const inHotIndex = p => graph.has(p)
for (let i = 0; i < hotYs.length; i++) {
for (let j = 0; j < hotXs.length; j++) {
const b = makePt(hotXs[j], hotYs[i])
if (!inHotIndex(b)) {
continue
}
if (j > 0) {
const a = makePt(hotXs[j - 1], hotYs[i])
if (inHotIndex(a)) {
graph.connect(a, b)
graph.connect(b, a)
connections.push({ a, b })
}
}
if (i > 0) {
const a = makePt(hotXs[j], hotYs[i - 1])
if (inHotIndex(a)) {
graph.connect(a, b)
graph.connect(b, a)
connections.push({ a, b })
}
}
}
}
return { graph, connections }
}
/**
* Solves the shotest path for the origin-destination path of the graph
* @param graph
* @param origin
* @param destination
*/
function shortestPath(graph, origin, destination) {
const originNode = graph.get(origin)
const destinationNode = graph.get(destination)
if (!originNode) {
throw new Error(`Origin node {${origin.x},${origin.y}} not found`)
}
if (!destinationNode) {
throw new Error(`Origin node {${origin.x},${origin.y}} not found`)
}
graph.calculateShortestPathFromSource(graph, originNode)
return destinationNode.shortestPath.map(n => n.data)
}
/**
* Given two segments represented by 3 points,
* determines if the second segment bends on an orthogonal direction or not, and which.
* @param a
* @param b
* @param c
* @return Bend direction, unknown if not orthogonal or 'none' if straight line
*/
function getBend(a, b, c) {
const equalX = a.x === b.x && b.x === c.x
const equalY = a.y === b.y && b.y === c.y
const segment1Horizontal = a.y === b.y
const segment1Vertical = a.x === b.x
const segment2Horizontal = b.y === c.y
const segment2Vertical = b.x === c.x
if (equalX || equalY) {
return "none"
}
if (!(segment1Vertical || segment1Horizontal) || !(segment2Vertical || segment2Horizontal)) {
return "unknown"
}
if (segment1Horizontal && segment2Vertical) {
return c.y > b.y ? "s" : "n"
} else if (segment1Vertical && segment2Horizontal) {
return c.x > b.x ? "e" : "w"
}
throw new Error("Nope")
}
/**
* Simplifies the path by removing unnecessary points, based on orthogonal pathways
* @param points
*/
function simplifyPath(points) {
if (points.length <= 2) {
return points
}
const r = [points[0]]
for (let i = 1; i < points.length; i++) {
const cur = points[i]
if (i === points.length - 1) {
r.push(cur)
break
}
const prev = points[i - 1]
const next = points[i + 1]
const bend = getBend(prev, cur, next)
if (bend !== "none") {
r.push(cur)
}
}
return r
}
/**
* Helps create the grid portion of the algorithm
*/
class Grid {
constructor() {
this._rows = 0
this._cols = 0
this.data = new Map()
}
set(row, column, rectangle) {
this._rows = Math.max(this.rows, row + 1)
this._cols = Math.max(this.columns, column + 1)
const rowMap = this.data.get(row) || this.data.set(row, new Map()).get(row)
rowMap.set(column, rectangle)
}
get(row, column) {
const rowMap = this.data.get(row)
if (rowMap) {
return rowMap.get(column) || null
}
return null
}
rectangles() {
const r = []
for (const [, data] of this.data) {
for (const [, rect] of data) {
r.push(rect)
}
}
return r
}
get columns() {
return this._cols
}
get rows() {
return this._rows
}
}
/**
* Main logic wrapped in a class to hold a space for potential future functionallity
*/
export class OrthogonalConnector {
constructor() {
this.byproduct = {
hRulers: [],
vRulers: [],
spots: [],
grid: [],
connections: []
}
}
getPoints(x, y, width, height) {
return [
{ name: "top", x: x + width / 2, y },
{ name: "bottom", x: x + width / 2, y: y + height },
{ name: "left", x, y: y + height / 2 },
{ name: "right", x: x + width, y: y + height / 2 }
]
}
assignSides(pointA, pointB) {
if (!pointA.side || !pointB.side) {
const sourcePoints = this.getPoints(pointA.shape.left, pointA.shape.top, pointA.shape.width, pointA.shape.height)
const targetPoints = this.getPoints(pointB.shape.left, pointB.shape.top, pointB.shape.width, pointB.shape.height)
let closestCombination = { source: null, target: null, distance: Infinity }
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
const distance = Math.sqrt(Math.pow(sourcePoints[i].x - targetPoints[j].x, 2) + Math.pow(sourcePoints[i].y - targetPoints[j].y, 2))
if (distance < closestCombination.distance) {
closestCombination = { source: sourcePoints[i].name, target: targetPoints[j].name, distance }
}
}
}
pointA.side = pointA.side || closestCombination.source
pointB.side = pointB.side || closestCombination.target
}
}
route(opts) {
const { pointA, pointB, globalBoundsMargin } = opts
this.assignSides(pointA, pointB)
const spots = []
const verticals = []
const horizontals = []
const sideA = pointA.side
const sideAVertical = isVerticalSide(sideA)
const sideB = pointB.side
const sideBVertical = isVerticalSide(sideB)
const originA = computePt(pointA)
const originB = computePt(pointB)
const shapeA = Rectangle.fromRect(pointA.shape)
const shapeB = Rectangle.fromRect(pointB.shape)
const bigBounds = Rectangle.fromRect(opts.globalBounds)
let shapeMargin = opts.shapeMargin
let inflatedA = shapeA.inflate(shapeMargin, shapeMargin)
let inflatedB = shapeB.inflate(shapeMargin, shapeMargin)
// Check bounding boxes collision
if (inflatedA.intersects(inflatedB)) {
shapeMargin = 0
inflatedA = shapeA
inflatedB = shapeB
}
const inflatedBounds = inflatedA.union(inflatedB).inflate(globalBoundsMargin, globalBoundsMargin)
// Curated bounds to stick to
const bounds = Rectangle.fromLTRB(
Math.max(inflatedBounds.left, bigBounds.left),
Math.max(inflatedBounds.top, bigBounds.top),
Math.min(inflatedBounds.right, bigBounds.right),
Math.min(inflatedBounds.bottom, bigBounds.bottom)
)
// Add edges to rulers
for (const b of [inflatedA, inflatedB]) {
verticals.push(b.left)
verticals.push(b.right)
horizontals.push(b.top)
horizontals.push(b.bottom)
}
// Rulers at origins of shapes
;(sideAVertical ? verticals : horizontals).push(sideAVertical ? originA.x : originA.y)
;(sideBVertical ? verticals : horizontals).push(sideBVertical ? originB.x : originB.y)
// Points of shape antennas
for (const connectorPt of [pointA, pointB]) {
const p = computePt(connectorPt)
const add = (dx, dy) => spots.push(makePt(p.x + dx, p.y + dy))
switch (connectorPt.side) {
case "top":
add(0, -shapeMargin)
break
case "right":
add(shapeMargin, 0)
break
case "bottom":
add(0, shapeMargin)
break
case "left":
add(-shapeMargin, 0)
break
}
}
// Sort rulers
verticals.sort((a, b) => a - b)
horizontals.sort((a, b) => a - b)
// Create grid
const grid = rulersToGrid(verticals, horizontals, bounds)
const gridPoints = gridToSpots(grid, [inflatedA, inflatedB])
// Add to spots
spots.push(...gridPoints)
// Create graph
const { graph, connections } = createGraph(spots)
// Origin and destination by extruding antennas
const origin = extrudeCp(pointA, shapeMargin)
const destination = extrudeCp(pointB, shapeMargin)
const start = computePt(pointA)
const end = computePt(pointB)
this.byproduct.spots = spots
this.byproduct.vRulers = verticals
this.byproduct.hRulers = horizontals
this.byproduct.grid = grid.rectangles()
this.byproduct.connections = connections
const path = shortestPath(graph, origin, destination)
if (path.length > 0) {
return simplifyPath([start, ...shortestPath(graph, origin, destination), end])
} else {
return []
}
}
}